diff --git a/OneWare.slnx b/OneWare.slnx
index 0395556d6..65093d645 100644
--- a/OneWare.slnx
+++ b/OneWare.slnx
@@ -128,6 +128,7 @@
+
diff --git a/src/OneWare.Essentials/Services/IToolCommandBuilder.cs b/src/OneWare.Essentials/Services/IToolCommandBuilder.cs
new file mode 100644
index 000000000..68d1cf541
--- /dev/null
+++ b/src/OneWare.Essentials/Services/IToolCommandBuilder.cs
@@ -0,0 +1,153 @@
+using OneWare.Essentials.Enums;
+using OneWare.Essentials.ToolEngine;
+
+namespace OneWare.Essentials.Services;
+
+///
+/// A fluent builder for creating instances.
+/// Supports cross-platform path handling, scripting placeholders, and container/networking configurations.
+///
+public interface IToolCommandBuilder
+{
+ ///
+ /// Sets the path to the executable file.
+ /// If not explicitly set, the ToolName provided during initialization will be used as the executable.
+ ///
+ IToolCommandBuilder WithExecutable(string path);
+
+ ///
+ /// Sets the working directory for the tool execution. Defaults to "."
+ ///
+ IToolCommandBuilder WithWorkingDirectory(string dir);
+
+ ///
+ /// Defines the status message and application state to be displayed in the UI during execution.
+ ///
+ IToolCommandBuilder WithStatus(string status, AppState state = AppState.Loading);
+
+ ///
+ /// Determines whether a timer should be displayed in the UI during the tool's execution.
+ ///
+ IToolCommandBuilder WithTimer(bool show);
+
+ ///
+ /// Registers a handler for the standard output stream (stdout).
+ ///
+ IToolCommandBuilder WithOutputHandler(Func? handler);
+
+ ///
+ /// Registers a handler for the error output stream (stderr).
+ ///
+ IToolCommandBuilder WithErrorHandler(Func? handler);
+
+ ///
+ /// Adds a simple string literal as a command-line argument.
+ ///
+ IToolCommandBuilder Add(string literal);
+
+ ///
+ /// Adds multiple string literals as command-line arguments.
+ ///
+ IToolCommandBuilder Add(params string[] literals);
+
+ ///
+ /// Adds a collection of string literals as command-line arguments.
+ ///
+ IToolCommandBuilder AddRange(IEnumerable literals);
+
+ ///
+ /// Adds an argument only if the specified condition is met.
+ ///
+ IToolCommandBuilder AddIf(bool condition, string literal);
+
+ ///
+ /// Adds an argument only if the provided string is not null or whitespace.
+ ///
+ IToolCommandBuilder AddIfNotNull(string? literal);
+
+ ///
+ /// Adds a file or directory path as an argument.
+ /// The path will be normalized according to the target OS (Windows/Linux) during preparation.
+ ///
+ IToolCommandBuilder AddPath(string path);
+
+ ///
+ /// Adds a collection of paths as arguments, ensuring OS-specific normalization for each.
+ ///
+ IToolCommandBuilder AddPaths(IEnumerable paths);
+
+ ///
+ /// Adds an option consisting of a flag and a value (e.g., "-o", "output.bin").
+ ///
+ IToolCommandBuilder AddOption(string flag, string value);
+
+ ///
+ /// Adds an option consisting of a flag and a value only if the value is not null or whitespace.
+ ///
+ IToolCommandBuilder AddOptionIfNotNull(string flag, string? value);
+
+ ///
+ /// Adds an option consisting of a flag and a path. The path will be normalized.
+ ///
+ IToolCommandBuilder AddPathOption(string flag, string path);
+
+ ///
+ /// Adds an option consisting of a flag and a path only if the path is not null or whitespace.
+ ///
+ IToolCommandBuilder AddPathOptionIfNotNull(string flag, string? path);
+
+ ///
+ /// Adds a complex command string using template placeholders.
+ /// Placeholders are treated as simple literals.
+ ///
+ IToolCommandBuilder AddScript(string template, params (string placeholder, string value)[] literals);
+
+ ///
+ /// Adds a complex command string using template placeholders.
+ /// Allows placeholders to be explicitly marked as paths for OS-specific normalization.
+ ///
+ IToolCommandBuilder AddScript(string template, params (string placeholder, string value, bool isPath)[] mappings);
+
+ ///
+ /// Parses a raw string (e.g., from user settings) into individual arguments, respecting quotes.
+ ///
+ IToolCommandBuilder AddRawArguments(string? rawArgs);
+
+ ///
+ /// Looks up a path in a dictionary by its key and adds it as a normalized path argument.
+ ///
+ IToolCommandBuilder AddPathFromMap(TKey key, IDictionary map) where TKey : notnull;
+
+ ///
+ /// Sets an environment variable for the tool process.
+ ///
+ IToolCommandBuilder WithEnvironmentVariable(string key, string value);
+
+ ///
+ /// Adds a collection of environment variables for the tool process.
+ ///
+ IToolCommandBuilder WithEnvironmentVariables(IDictionary variables);
+
+ ///
+ /// Sets an environment variable only if the specified condition is met.
+ ///
+ IToolCommandBuilder WithEnvironmentVariableIf(bool condition, string key, string value);
+
+ ///
+ /// Declares a network port that the tool listens on internally.
+ /// Used for firewall configuration or container port exposure.
+ ///
+ IToolCommandBuilder WithExposedPort(int port, string protocol = "TCP");
+
+ ///
+ /// Defines a network port mapping.
+ /// Native runners typically use the guestPort directly, while container runners map the hostPort to the guestPort.
+ ///
+ IToolCommandBuilder AddPortMapping(int hostPort, int guestPort, string protocol = "TCP");
+
+ ///
+ /// Builds the final instance.
+ ///
+ /// Thrown if neither ToolName nor Executable is set.
+ ToolCommand Build();
+}
\ No newline at end of file
diff --git a/src/OneWare.Essentials/Services/IToolExecutionDispatcherService.cs b/src/OneWare.Essentials/Services/IToolExecutionDispatcherService.cs
index ebd3d0e56..fc148242a 100644
--- a/src/OneWare.Essentials/Services/IToolExecutionDispatcherService.cs
+++ b/src/OneWare.Essentials/Services/IToolExecutionDispatcherService.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics;
using OneWare.Essentials.ToolEngine;
namespace OneWare.Essentials.Services;
@@ -8,4 +9,20 @@ public interface IToolExecutionDispatcherService
/// Executes a tool command using the configured execution strategy.
///
public Task<(bool success, string output)> ExecuteAsync(ToolCommand command);
+
+ ///
+ /// Starts the tool as a background process without waiting for its completion or capturing its output.
+ ///
+ /// The run configuration for the strategy.
+ /// A to the started process, allowing it to be garbage collected if no other references exist.
+ public WeakReference StartWeakProcess(ToolCommand command);
+
+ ///
+ /// Creates a new instance of for a specific tool.
+ /// This is the entry point for configuring a tool command with specific arguments, environment variables, and mappings.
+ ///
+ /// The name of the tool to be executed (e.g., "yosys", "gcc").
+ /// Used for logging and identifying the executable if no path is provided.
+ /// A fluent builder instance to configure the command.
+ public IToolCommandBuilder CreateToolCommandBuilder(string toolName);
}
diff --git a/src/OneWare.Essentials/Services/IToolExecutionStrategy.cs b/src/OneWare.Essentials/Services/IToolExecutionStrategy.cs
index b203eab7e..f1de8af56 100644
--- a/src/OneWare.Essentials/Services/IToolExecutionStrategy.cs
+++ b/src/OneWare.Essentials/Services/IToolExecutionStrategy.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics;
using OneWare.Essentials.ToolEngine;
namespace OneWare.Essentials.Services;
@@ -10,6 +11,14 @@ public interface IToolExecutionStrategy
/// The run configuration for the strategy
///
Task<(bool success, string output)> ExecuteAsync(ToolCommand command);
+
+
+ ///
+ /// Starts the tool as a background process without waiting for its completion or capturing its output.
+ ///
+ /// The run configuration for the strategy.
+ /// A to the started process, allowing it to be garbage collected if no other references exist.
+ public WeakReference StartWeakProcess(ToolCommand command);
///
/// Returns the display name for a strategy.
diff --git a/src/OneWare.Essentials/ToolEngine/ICommandArgument.cs b/src/OneWare.Essentials/ToolEngine/ICommandArgument.cs
new file mode 100644
index 000000000..3450edb25
--- /dev/null
+++ b/src/OneWare.Essentials/ToolEngine/ICommandArgument.cs
@@ -0,0 +1,10 @@
+using System.Runtime.InteropServices;
+
+namespace OneWare.Essentials.ToolEngine;
+
+public interface ICommandArgument
+{
+ string GetArgument();
+
+ void Prepare(OSPlatform osPlatform, Func? pathMapper = null);
+}
\ No newline at end of file
diff --git a/src/OneWare.Essentials/ToolEngine/Strategies/NativeStrategy.cs b/src/OneWare.Essentials/ToolEngine/Strategies/NativeStrategy.cs
index 6ae15c641..b4bfac9bc 100644
--- a/src/OneWare.Essentials/ToolEngine/Strategies/NativeStrategy.cs
+++ b/src/OneWare.Essentials/ToolEngine/Strategies/NativeStrategy.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics;
using OneWare.Essentials.Models;
using OneWare.Essentials.Services;
@@ -5,16 +6,23 @@ namespace OneWare.Essentials.ToolEngine.Strategies;
public class NativeStrategy : IToolExecutionStrategy
{
- private const string ToolKey = "NativeExecutionStrategy";
+ public const string ToolKey = "NativeExecutionStrategy";
public Task<(bool success, string output)> ExecuteAsync(ToolCommand command)
{
- IChildProcessService childProcessService = ContainerLocator.Container.Resolve();
+ var childProcessService = ContainerLocator.Container.Resolve();
return childProcessService.ExecuteShellAsync(command.Executable ?? command.ToolName, command.Arguments, command.WorkingDirectory, command.StatusMessage,
command.State,
command.ShowTimer, command.OutputHandler, command.ErrorHandler);
}
+ public WeakReference StartWeakProcess(ToolCommand command)
+ {
+ var childProcessService = ContainerLocator.Container.Resolve();
+ return childProcessService.StartWeakProcess(command.Executable ?? command.ToolName, command.Arguments,
+ command.WorkingDirectory);
+ }
+
public string GetStrategyName()
{
return "Native Execution Strategy";
diff --git a/src/OneWare.Essentials/ToolEngine/ToolCommand.cs b/src/OneWare.Essentials/ToolEngine/ToolCommand.cs
index f777425fb..33f481e6a 100644
--- a/src/OneWare.Essentials/ToolEngine/ToolCommand.cs
+++ b/src/OneWare.Essentials/ToolEngine/ToolCommand.cs
@@ -1,4 +1,7 @@
+using System.Runtime.InteropServices;
using OneWare.Essentials.Enums;
+using OneWare.Essentials.Services;
+using OneWare.Essentials.ToolEngine;
namespace OneWare.Essentials.ToolEngine;
@@ -6,15 +9,33 @@ public class ToolCommand
{
public required string ToolName { get; init; }
public string? Executable { get; init; }
- public IReadOnlyCollection Arguments { get; init; } = [];
+
+ public IReadOnlyCollection ExposedPorts { get; init; } = Array.Empty();
+
+ public IReadOnlyCollection PortMappings { get; init; } = Array.Empty();
+
+ public IReadOnlyCollection Arguments =>
+ CommandArguments.Select(x => x.GetArgument()).ToList().AsReadOnly();
+
+ public required IReadOnlyCollection CommandArguments { get; init; }
public string WorkingDirectory { get; init; } = ".";
public string StatusMessage { get; init; } = "Running tool...";
public AppState State { get; init; } = AppState.Loading;
public bool ShowTimer { get; init; }
+ public IReadOnlyDictionary EnvironmentVariables { get; init; } = new Dictionary();
+
public Func? OutputHandler { get; init; }
public Func? ErrorHandler { get; init; }
+ public void PrepareCommand(OSPlatform osPlatform, Func? pathMapper = null)
+ {
+ foreach (var argument in CommandArguments)
+ {
+ argument.Prepare(osPlatform, pathMapper);
+ }
+ }
+ [Obsolete("Use IToolExecutionDispatcherService.CreateToolCommandBuilder instead.")]
public static ToolCommand FromShellParams(
string path,
IReadOnlyCollection arguments,
@@ -25,39 +46,19 @@ public static ToolCommand FromShellParams(
Func? outputAction = null,
Func? errorAction = null)
{
- return new ToolCommand
- {
- ToolName = Path.GetFileNameWithoutExtension(path),
- Executable = path,
- Arguments = arguments,
- WorkingDirectory = workingDirectory,
- StatusMessage = status,
- State = state,
- ShowTimer = showTimer,
- OutputHandler = outputAction,
- ErrorHandler = errorAction
- };
+ return ContainerLocator.Current.Resolve().
+ CreateToolCommandBuilder(Path.GetFileNameWithoutExtension(path))
+ .WithExecutable(path)
+ .WithWorkingDirectory(workingDirectory)
+ .WithStatus(status, state)
+ .WithOutputHandler(outputAction)
+ .WithErrorHandler(errorAction)
+ .WithTimer(showTimer)
+ .AddRange(arguments)
+ .Build();
}
}
-public class ToolContext
-{
- public ToolContext(string name, string description, string key, List? toolNames = null)
- {
- Name = name;
- Description = description;
- Key = key;
- ToolNames = toolNames ?? [];
- }
-
- public string Name { get; init; }
- public string Description { get; init; }
- public string Key { get; init; }
+public record ToolPort(int Number, string Protocol = "TCP");
- public List ToolNames { get; init; }
-}
-
-public class ToolConfiguration
-{
- public readonly Dictionary StrategyMapping = new();
-}
\ No newline at end of file
+public record ToolPortMapping(ToolPort Host, ToolPort Guest);
\ No newline at end of file
diff --git a/src/OneWare.Essentials/ToolEngine/ToolConfiguration.cs b/src/OneWare.Essentials/ToolEngine/ToolConfiguration.cs
new file mode 100644
index 000000000..8d74caa22
--- /dev/null
+++ b/src/OneWare.Essentials/ToolEngine/ToolConfiguration.cs
@@ -0,0 +1,6 @@
+namespace OneWare.Essentials.ToolEngine;
+
+public class ToolConfiguration
+{
+ public readonly Dictionary StrategyMapping = new();
+}
\ No newline at end of file
diff --git a/src/OneWare.Essentials/ToolEngine/ToolContext.cs b/src/OneWare.Essentials/ToolEngine/ToolContext.cs
new file mode 100644
index 000000000..c178d57df
--- /dev/null
+++ b/src/OneWare.Essentials/ToolEngine/ToolContext.cs
@@ -0,0 +1,10 @@
+namespace OneWare.Essentials.ToolEngine;
+
+public class ToolContext(string name, string description, string key, List? toolNames = null)
+{
+ public string Name { get; init; } = name;
+ public string Description { get; init; } = description;
+ public string Key { get; init; } = key;
+
+ public List ToolNames { get; init; } = toolNames ?? [];
+}
\ No newline at end of file
diff --git a/src/OneWare.OssCadSuiteIntegration/Loaders/OpenFpgaLoader.cs b/src/OneWare.OssCadSuiteIntegration/Loaders/OpenFpgaLoader.cs
index 1b7acaa50..238d394a4 100644
--- a/src/OneWare.OssCadSuiteIntegration/Loaders/OpenFpgaLoader.cs
+++ b/src/OneWare.OssCadSuiteIntegration/Loaders/OpenFpgaLoader.cs
@@ -1,8 +1,6 @@
using Avalonia.Threading;
using Microsoft.Extensions.Logging;
-using OneWare.Essentials.Enums;
using OneWare.Essentials.Services;
-using OneWare.Essentials.ToolEngine;
using OneWare.OssCadSuiteIntegration.Helpers;
using OneWare.UniversalFpgaProjectSystem.Models;
using OneWare.UniversalFpgaProjectSystem.Parser;
@@ -14,6 +12,14 @@ public class OpenFpgaLoader(ISettingsService settingsService, ILogger logger, IO
IToolExecutionDispatcherService toolExecutionDispatcherService)
: IFpgaLoader
{
+
+ private static readonly Dictionary BitstreamPaths = new()
+ {
+ { "bin", "./build/pack.bin" },
+ { "bit", "./build/pack.bit" },
+ { "fs", "./build/pack.fs" }
+ };
+
public const string LoaderId = "openFpgaLoader";
public string Id => LoaderId;
public string Name => "OpenFpgaLoader";
@@ -21,71 +27,36 @@ public class OpenFpgaLoader(ISettingsService settingsService, ILogger logger, IO
public async Task DownloadAsync(UniversalFpgaProjectRoot project)
{
var fpga = project.Properties.GetString("Fpga") ?? "unknown";
-
var longTerm = settingsService.GetSettingValue("UniversalFpgaProjectSystem_LongTermProgramming");
-
var properties = FpgaSettingsParser.LoadSettings(project, fpga);
var board = properties.GetValueOrDefault("openFpgaLoaderBoard");
var cable = properties.GetValueOrDefault("OpenFpgaLoader_Cable");
-
- List openFpgaLoaderArguments = [];
- if (!string.IsNullOrEmpty(board))
- {
- openFpgaLoaderArguments.AddRange(["-b", board]);
- }
- else if (!string.IsNullOrEmpty(cable))
- {
- openFpgaLoaderArguments.AddRange(["-c", cable]);
- }
- else
- {
- logger.Error("Board/Cable not supported/configured for openFPGALoader!");
- return;
- }
-
- if (longTerm)
- {
- if (properties.GetValueOrDefault("openFpgaLoaderLongTermFlags") is { } longFlags)
- openFpgaLoaderArguments.Add(longFlags);
-
- openFpgaLoaderArguments.Add("-f");
- }
- else if (properties.GetValueOrDefault("openFpgaLoaderShortTermFlags") is { } shortFlags)
- {
- openFpgaLoaderArguments.Add(shortFlags);
- }
-
- openFpgaLoaderArguments.AddRange(properties.GetValueOrDefault("OpenFpgaLoaderFlags")?.Split(' ',
- StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) ?? []);
-
+ var loaderPath = settingsService.GetSettingValue(OssCadSuiteHelper.OpenFpgaLoaderPathSetting);
var bitstreamFormat = properties
.GetValueOrDefault("openFpgaLoaderBitstreamFormat", "bin");
+
+ var command = toolExecutionDispatcherService.CreateToolCommandBuilder("openFPGALoader")
+ .WithExecutable(loaderPath)
+ .WithWorkingDirectory(project.FullPath)
+ .WithStatus($"Running {loaderPath}...")
+ .WithTimer(true)
+ .WithOutputHandler(s =>
+ {
+ Dispatcher.UIThread.Post(() => { outputService.WriteLine(s); }); return true;
+ })
+
+ .AddOptionIfNotNull("-b", board)
+ .AddOptionIfNotNull("-c", string.IsNullOrEmpty(board) ? cable : null)
+
+ .AddIf(longTerm, "-f")
+ .AddRawArguments(longTerm
+ ? properties.GetValueOrDefault("openFpgaLoaderLongTermFlags")
+ : properties.GetValueOrDefault("openFpgaLoaderShortTermFlags"))
+ .AddRawArguments(properties.GetValueOrDefault("OpenFpgaLoaderFlags"))
+ .AddPathFromMap(bitstreamFormat, BitstreamPaths).Build();
- switch (bitstreamFormat)
- {
- case "bin":
- openFpgaLoaderArguments.Add("./build/pack.bin");
- break;
- case "bit":
- openFpgaLoaderArguments.Add("./build/pack.bit");
- break;
- case "fs":
- openFpgaLoaderArguments.Add("./build/pack.fs");
- break;
- default:
- outputService.WriteLine($"Could not find output type: {bitstreamFormat}");
- return;
- }
-
- var path = settingsService.GetSettingValue(OssCadSuiteHelper.OpenFpgaLoaderPathSetting);
outputService.WriteLine("Starting OpenFpgaLoader ...");
- var command = ToolCommand.FromShellParams(path, openFpgaLoaderArguments!,
- project.FullPath, $"Running {path}...", AppState.Loading, true, null, s =>
- {
- Dispatcher.UIThread.Post(() => { outputService.WriteLine(s); });
- return true;
- });
try
{
diff --git a/src/OneWare.OssCadSuiteIntegration/OssCadSuiteIntegrationModule.cs b/src/OneWare.OssCadSuiteIntegration/OssCadSuiteIntegrationModule.cs
index 15067eec2..ceb3871d8 100644
--- a/src/OneWare.OssCadSuiteIntegration/OssCadSuiteIntegrationModule.cs
+++ b/src/OneWare.OssCadSuiteIntegration/OssCadSuiteIntegrationModule.cs
@@ -52,12 +52,12 @@ public override void Initialize(IServiceProvider serviceProvider)
var toolService = serviceProvider.Resolve();
toolService.Register(new ToolContext("yosys", "Synth Tool", "yosys"), new NativeStrategy());
- toolService.Register(new ToolContext("nextpnr-ecp5", "Place and Routing Tool", "nextpnr-ecp5"), new NativeStrategy());
- toolService.Register(new ToolContext("nextpnr-generic", "Place and Routing Tool", "nextpnr-generic"),new NativeStrategy());
- toolService.Register(new ToolContext("nextpnr-himbaechel", "Place and Routing Tool", "nextpnr-himbaechel"), new NativeStrategy());
- toolService.Register(new ToolContext("nextpnr-ice40", "Place and Routing Tool", "nextpnr-ice40"), new NativeStrategy());
- toolService.Register(new ToolContext("nextpnr-machxo2", "Place and Routing Tool", "nextpnr-machxo2"), new NativeStrategy());
- toolService.Register(new ToolContext("nextpnr-nexus", "Place and Routing Tool", "nextpnr-nexus"), new NativeStrategy());
+ toolService.Register(new ToolContext("nextpnr-ecp5", "Place and Routing Tool for ECP5", "nextpnr-ecp5"), new NativeStrategy());
+ toolService.Register(new ToolContext("nextpnr-generic", "Place and Routing Tool for generic devices", "nextpnr-generic"),new NativeStrategy());
+ toolService.Register(new ToolContext("nextpnr-himbaechel", "Place and Routing Tool for large archs", "nextpnr-himbaechel"), new NativeStrategy());
+ toolService.Register(new ToolContext("nextpnr-ice40", "Place and Routing Tool for ICE40", "nextpnr-ice40"), new NativeStrategy());
+ toolService.Register(new ToolContext("nextpnr-machxo2", "Place and Routing Tool MachXO2", "nextpnr-machxo2"), new NativeStrategy());
+ toolService.Register(new ToolContext("nextpnr-nexus", "Place and Routing Tool for nexus", "nextpnr-nexus"), new NativeStrategy());
toolService.Register(new ToolContext("openFPGALoader", "FPGA Loader", "openFPGALoader"), new NativeStrategy());
toolService.Register(new ToolContext("iceprog", "Packing", "FPGA Loader"), new NativeStrategy());
@@ -68,6 +68,8 @@ public override void Initialize(IServiceProvider serviceProvider)
toolService.Register(new ToolContext("gmupack", "Packing", "gmupack"), new NativeStrategy());
toolService.Register(new ToolContext("gtkwave", "Visualisation", "gtkwave"), new NativeStrategy());
+ toolService.Register(new ToolContext("iverilog", "Simulation", "iverilog"), new NativeStrategy());
+ toolService.Register(new ToolContext("vvp", "Simulation", "vvp"), new NativeStrategy());
serviceProvider.Resolve().RegisterPackage(OssCadSuiteHelper.OssCadPackage);
diff --git a/src/OneWare.OssCadSuiteIntegration/Simulators/IcarusVerilogSimulator.cs b/src/OneWare.OssCadSuiteIntegration/Simulators/IcarusVerilogSimulator.cs
index 795bbe31f..8268da80a 100644
--- a/src/OneWare.OssCadSuiteIntegration/Simulators/IcarusVerilogSimulator.cs
+++ b/src/OneWare.OssCadSuiteIntegration/Simulators/IcarusVerilogSimulator.cs
@@ -1,8 +1,10 @@
using System.Text.RegularExpressions;
+using Microsoft.Extensions.Logging;
using OneWare.Essentials.Enums;
using OneWare.Essentials.Extensions;
using OneWare.Essentials.Models;
using OneWare.Essentials.Services;
+using OneWare.Essentials.ToolEngine;
using OneWare.OssCadSuiteIntegration.Tools;
using OneWare.OssCadSuiteIntegration.ViewModels;
using OneWare.OssCadSuiteIntegration.Views;
@@ -14,15 +16,18 @@ namespace OneWare.OssCadSuiteIntegration.Simulators;
public class IcarusVerilogSimulator : IFpgaSimulator
{
- private readonly IChildProcessService _childProcessService;
private readonly IMainDockService _mainDockService;
private readonly IProjectExplorerService _projectExplorerService;
private readonly GtkWaveService _gtkWaveService;
+ private readonly IToolExecutionDispatcherService _toolExecutionDispatcherService;
+ private readonly ILogger _logger;
- public IcarusVerilogSimulator(IChildProcessService childProcessService, IMainDockService mainDockService,
- IProjectExplorerService projectExplorerService, GtkWaveService gtkWaveService)
+ public IcarusVerilogSimulator(ILogger logger, IMainDockService mainDockService,
+ IProjectExplorerService projectExplorerService, GtkWaveService gtkWaveService,
+ IToolExecutionDispatcherService toolExecutionDispatcherService)
{
- _childProcessService = childProcessService;
+ _logger = logger;
+ _toolExecutionDispatcherService = toolExecutionDispatcherService;
_mainDockService = mainDockService;
_projectExplorerService = projectExplorerService;
_gtkWaveService = gtkWaveService;
@@ -59,36 +64,42 @@ public async Task SimulateAsync(string fullPath)
.Select(x => x.ToUnixPath());
_mainDockService.Show();
-
- List icarusVerilogArguments = [];
- icarusVerilogArguments.AddRange(["-o", vvpPath]);
var settings = await TestBenchContextManager.LoadContextAsync(fullPath);
var waveOutput = settings.GetBenchProperty(nameof(IcarusVerilogSimulatorToolbarViewModel.WaveOutputFormat)) ?? "VCD";
-
- var waveOutputArgument = waveOutput switch
- {
- "VCD" => "",
- "LXT2" => "-lxt2",
- "FST" => "-fst",
- _ => string.Empty
- };
- var additionalGhdlOptions =
- settings.GetBenchProperty(nameof(IcarusVerilogSimulatorToolbarViewModel.IcarusVerilogArguments));
- if (additionalGhdlOptions != null) icarusVerilogArguments.AddRange(additionalGhdlOptions.Split(' '));
+ var command = _toolExecutionDispatcherService.CreateToolCommandBuilder("iverilog")
+ .WithWorkingDirectory(root.FullPath)
+ .WithStatus("Running IVerilog..", AppState.Loading)
+ .WithTimer(true)
+ .AddPathOption("-o", vvpPath)
+ .AddRawArguments(settings.GetBenchProperty(nameof(IcarusVerilogSimulatorToolbarViewModel.IcarusVerilogArguments)))
+ .AddPaths(verilogFiles)
+ .Build();
- icarusVerilogArguments.AddRange(verilogFiles);
+ var (resultIVerilog, _) = await _toolExecutionDispatcherService.ExecuteAsync(command);
+
+ if (!resultIVerilog)
+ {
+ _logger.LogWarning("IVerilog failed");
+ return false;
+ }
- var (result, _) = await _childProcessService.ExecuteShellAsync("iverilog", icarusVerilogArguments,
- root.FullPath, "Running IVerilog...", AppState.Loading, true);
-
- if (!result) return false;
-
- var (success2, output) = await _childProcessService.ExecuteShellAsync("vvp", [vvpPath, waveOutputArgument],
- root.FullPath, "Running VVP Simulation...", AppState.Loading, true);
- if (!success2) return false;
+ var vvpCommand = _toolExecutionDispatcherService.CreateToolCommandBuilder("vvp").WithWorkingDirectory(root.FullPath)
+ .WithStatus("Running VPP Simulation", AppState.Loading)
+ .WithTimer(true)
+ .Add(vvpPath)
+ .AddIf(waveOutput == "LXT2", "-lxt2")
+ .AddIf(waveOutput == "FST", "-fst").Build();
+
+ var (resultVvp, outputVvp) = await _toolExecutionDispatcherService.ExecuteAsync(vvpCommand);
+
+ if (!resultVvp)
+ {
+ _logger.LogWarning("VVP failed");
+ return false;
+ }
var escapedEnding = waveOutput switch
{
@@ -100,7 +111,7 @@ public async Task SimulateAsync(string fullPath)
var vcdFileRegex = new Regex($@".*info: dumpfile\s+(.+\{escapedEnding})\s+opened for output.");
- var match = vcdFileRegex.Match(output);
+ var match = vcdFileRegex.Match(outputVvp);
if (!match.Success) return true;
var fileRelativePath = match.Groups[1].Value;
diff --git a/src/OneWare.OssCadSuiteIntegration/Tools/GtkWaveService.cs b/src/OneWare.OssCadSuiteIntegration/Tools/GtkWaveService.cs
index 0eb287f20..ea225edc5 100644
--- a/src/OneWare.OssCadSuiteIntegration/Tools/GtkWaveService.cs
+++ b/src/OneWare.OssCadSuiteIntegration/Tools/GtkWaveService.cs
@@ -1,9 +1,10 @@
using OneWare.Essentials.Services;
+using OneWare.Essentials.ToolEngine;
using OneWare.UniversalFpgaProjectSystem.Context;
namespace OneWare.OssCadSuiteIntegration.Tools;
-public class GtkWaveService(IChildProcessService childProcessService)
+public class GtkWaveService(IToolExecutionDispatcherService toolExecutionDispatcherService)
{
private static readonly string[] GtkProperties = ["GtkwSaveFile", "GtkwWaveArgs"];
public static readonly string[] GtkWaveformEndings = [".vcd", ".ghw", ".fst", ".lxt"];
@@ -11,13 +12,22 @@ public class GtkWaveService(IChildProcessService childProcessService)
public async Task OpenInGtkWaveAsync(string filePath)
{
var context = await TestBenchContextManager.LoadContextAsync(filePath);
- List args = [Path.GetFileName(filePath)];
-
+ var directory = Path.GetDirectoryName(filePath) ?? string.Empty;
+
+ var builder = toolExecutionDispatcherService.CreateToolCommandBuilder("gtkwave")
+ .WithWorkingDirectory(directory)
+ .Add(Path.GetFileName(filePath));
+
foreach (var property in GtkProperties)
- if (context.Properties.TryGetPropertyValue(property, out var jsonNode) && jsonNode != null && jsonNode.GetValueKind() == System.Text.Json.JsonValueKind.String)
- args.Add(jsonNode.GetValue());
+ {
+ if (context.Properties.TryGetPropertyValue(property, out var jsonNode) &&
+ jsonNode?.GetValueKind() == System.Text.Json.JsonValueKind.String)
+ {
+ builder.AddIfNotNull(jsonNode.GetValue());
+ }
+ }
- // save file has to be provided as second argument without "--save"
- childProcessService.StartWeakProcess("gtkwave", args, Path.GetDirectoryName(filePath) ?? "");
+ var toolCommand = builder.Build();
+ toolExecutionDispatcherService.StartWeakProcess(toolCommand);
}
}
diff --git a/src/OneWare.OssCadSuiteIntegration/Yosys/YosysService.cs b/src/OneWare.OssCadSuiteIntegration/Yosys/YosysService.cs
index 0b5146f0f..167e9b584 100644
--- a/src/OneWare.OssCadSuiteIntegration/Yosys/YosysService.cs
+++ b/src/OneWare.OssCadSuiteIntegration/Yosys/YosysService.cs
@@ -7,7 +7,6 @@
using OneWare.Essentials.Services;
using OneWare.Essentials.ToolEngine;
using OneWare.OssCadSuiteIntegration.Models;
-using OneWare.ToolEngine.Services;
using OneWare.OssCadSuiteIntegration.Tools;
using OneWare.UniversalFpgaProjectSystem.Fpga;
using OneWare.UniversalFpgaProjectSystem.Models;
@@ -16,11 +15,9 @@
namespace OneWare.OssCadSuiteIntegration.Yosys;
public class YosysService(
- IChildProcessService childProcessService,
ILogger logger,
IOutputService outputService,
IMainDockService dockService,
- IToolService toolService,
IToolExecutionDispatcherService toolExecutionDispatcherService)
{
public async Task CompileAsync(UniversalFpgaProjectRoot project, FpgaModel fpgaModel)
@@ -63,58 +60,32 @@ public async Task SynthAsync(UniversalFpgaProjectRoot project, FpgaModel f
}
public async Task SynthAsync(UniversalFpgaProjectRoot project, FpgaModel fpgaModel,
- IEnumerable? mandatoryFiles)
+ IEnumerable? mandatoryFiles = null)
{
try
{
var properties = FpgaSettingsParser.LoadSettings(project, fpgaModel.Fpga.Name);
-
var top = project.TopEntity ?? throw new Exception("TopEntity not set!");
var includedFiles = project.GetFiles("*.v").Concat(project.GetFiles("*.sv"))
.Where(x => !project.IsCompileExcluded(x))
- .Where(x => !project.IsTestBench(x));
-
+ .Where(x => !project.IsTestBench(x))
+ .ToList();
+
var genVerilogPath = Path.Combine(project.RootFolderPath, "build", "gen_verilog");
if (Directory.Exists(genVerilogPath))
{
- var generatedFiles = Directory.EnumerateFiles(genVerilogPath, "*.v",
- SearchOption.AllDirectories);
- includedFiles = includedFiles.Concat(generatedFiles);
+ includedFiles.AddRange(Directory.EnumerateFiles(genVerilogPath, "*.v", SearchOption.AllDirectories));
}
var yosysSynthTool = properties.GetValueOrDefault("yosysToolchainYosysSynthTool") ??
throw new Exception("Yosys Tool not set!");
-
- var yosysCommand = properties.GetValueOrDefault("yosysToolchainCommand") ?? "";
- var yosysQuiet = Boolean.Parse(properties.GetValueOrDefault("yosysQuietFlag") ?? "true");
- List yosysArguments = [];
-
- if (yosysQuiet)
- yosysArguments.Add("-q");
-
- if (string.IsNullOrWhiteSpace(yosysCommand))
- {
- yosysArguments.AddRange(["-p", $"{yosysSynthTool} -json build/synth.json"]);
- }
- else
- {
- yosysCommand = yosysCommand.Replace("$TOP", Path.GetFileNameWithoutExtension(top));
- yosysCommand = yosysCommand.Replace("$SYNTH_TOOL", yosysSynthTool);
- yosysCommand = yosysCommand.Replace("$OUTPUT", "build/synth.json");
-
- yosysArguments.AddRange(["-p", yosysCommand]);
- }
-
- yosysArguments.AddRange(properties.GetValueOrDefault("yosysToolchainYosysFlags")?.Split(' ',
- StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) ?? []);
-
- yosysArguments.AddRange(includedFiles);
-
- yosysArguments.AddRange(mandatoryFiles ?? []);
-
- var command = ToolCommand.FromShellParams("yosys", yosysArguments, project.FullPath,
- "Running yosys...", AppState.Loading, true, x =>
+
+ var builder = toolExecutionDispatcherService.CreateToolCommandBuilder("yosys")
+ .WithWorkingDirectory(project.FullPath)
+ .WithStatus("Running yosys...")
+ .WithTimer(true)
+ .WithOutputHandler(x =>
{
if (x.StartsWith("Error:"))
{
@@ -126,8 +97,40 @@ public async Task SynthAsync(UniversalFpgaProjectRoot project, FpgaModel f
return true;
});
- var (success, _) = await toolExecutionDispatcherService.ExecuteAsync(command);
+ bool.TryParse(properties.GetValueOrDefault("yosysQuietFlag") ?? "true", out var quiet);
+ builder.AddIf(quiet, "-q");
+
+ var customCommandTemplate = properties.GetValueOrDefault("yosysToolchainCommand");
+ builder.Add("-p");
+
+ if (string.IsNullOrWhiteSpace(customCommandTemplate))
+ {
+ builder.AddScript("{synthTool} -json {output}",
+ ("{synthTool}", yosysSynthTool, false),
+ ("{output}", "build/synth.json", true)
+ );
+ }
+ else
+ {
+ builder.AddScript(customCommandTemplate,
+ ("$TOP", Path.GetFileNameWithoutExtension(top), false),
+ ("$SYNTH_TOOL", yosysSynthTool, false),
+ ("$OUTPUT", "build/synth.json", true)
+ );
+ }
+
+ builder.AddRawArguments(properties.GetValueOrDefault("yosysToolchainYosysFlags"));
+ builder.AddPaths(includedFiles);
+
+ if (mandatoryFiles != null)
+ {
+ builder.AddPaths(mandatoryFiles);
+ }
+
+ var command = builder.Build();
+
+ var (success, _) = await toolExecutionDispatcherService.ExecuteAsync(command);
return success;
}
catch (Exception e)
@@ -148,82 +151,76 @@ private async Task RunNextpnrAsync(UniversalFpgaProjectRoot project, FpgaM
try
{
var properties = FpgaSettingsParser.LoadSettings(project, fpgaModel.Fpga.Name);
-
var nextPnrTool = properties.GetValueOrDefault("yosysToolchainNextPnrTool")
?? throw new Exception("NextPnr Tool not set!");
- var pcfFile = YosysSettingHelper.GetConstraintFile(project);
- var cFileType = properties
- .GetValueOrDefault("yosysToolchainConstraintFileType", "pcf");
+ var builder = toolExecutionDispatcherService.CreateToolCommandBuilder(nextPnrTool)
+ .WithWorkingDirectory(project.FullPath)
+ .WithStatus($"Running {nextPnrTool}...")
+ .WithTimer(true)
+ .WithErrorHandler(s =>
+ {
+ Dispatcher.UIThread.Post(() => { outputService.WriteLine(s); });
+ return true;
+ });
+
+ builder.AddPathOption("--json", "./build/synth.json");
- var nextPnrArguments = new List
- {
- "--json", "./build/synth.json",
- };
+ var pcfFile = YosysSettingHelper.GetConstraintFile(project);
+ var cFileType = properties.GetValueOrDefault("yosysToolchainConstraintFileType", "pcf");
switch (cFileType)
{
case "pcf":
- nextPnrArguments.Add("--pcf");
- nextPnrArguments.Add(pcfFile);
+ builder.Add("--pcf").AddPath(pcfFile);
break;
case "ccf":
var absolutePcfPath = Path.Combine(project.RootFolderPath, pcfFile);
outputService.WriteLine($"Converting {absolutePcfPath} to CCF File");
- if (Path.Exists(absolutePcfPath))
- ConstraintFileHelper.Convert(absolutePcfPath, Path.Combine(project.RootFolderPath,
- "./build/constrains.ccf"));
+
+ if (File.Exists(absolutePcfPath))
+ {
+ ConstraintFileHelper.Convert(absolutePcfPath,
+ Path.Combine(project.RootFolderPath, "./build/constrains.ccf"));
+ }
else
{
outputService.WriteLine($"Could not generate CCF file from {pcfFile}");
return false;
}
- nextPnrArguments.Add("-o");
- nextPnrArguments.Add($"ccf=./build/constrains.ccf");
+ builder.Add("-o");
+ builder.AddScript("ccf={path}", ("{path}", "./build/constrains.ccf"));
break;
default:
outputService.WriteLine($"Could not find Constraint file type: {cFileType}");
return false;
}
- var cOutputType = properties
- .GetValueOrDefault("yosysToolchainOutputType", "asc");
-
+ var cOutputType = properties.GetValueOrDefault("yosysToolchainOutputType", "asc");
switch (cOutputType)
{
case "asc":
- nextPnrArguments.Add("--asc");
- nextPnrArguments.Add("./build/nextpnr.asc");
+ builder.Add("--asc").AddPath("./build/nextpnr.asc");
break;
case "txt":
- nextPnrArguments.Add("-o");
- nextPnrArguments.Add("out=./build/impl.txt");
+ builder.Add("-o");
+ builder.AddScript("out={path}", ("{path}", "./build/impl.txt"));
break;
default:
outputService.WriteLine($"Could not find output type: {cOutputType}");
return false;
}
+ if (withGui) builder.Add("--gui");
- if (withGui)
- nextPnrArguments.Add("--gui");
+ var extraFlags = properties.GetValueOrDefault("yosysToolchainNextPnrFlags")?
+ .Split(' ', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) ?? [];
- nextPnrArguments.AddRange(properties
- .GetValueOrDefault("yosysToolchainNextPnrFlags")?
- .Split(' ',
- StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
- ?? Array.Empty());
-
- var command = ToolCommand.FromShellParams(nextPnrTool, nextPnrArguments,
- project.FullPath, $"Running {nextPnrTool}...", AppState.Loading, true, null, s =>
- {
- Dispatcher.UIThread.Post(() => { outputService.WriteLine(s); });
- return true;
- });
+ foreach (var flag in extraFlags) builder.Add(flag);
+ var command = builder.Build();
var status = await toolExecutionDispatcherService.ExecuteAsync(command);
-
return status.success;
}
catch (Exception e)
@@ -238,62 +235,54 @@ public async Task AssembleAsync(UniversalFpgaProjectRoot project, FpgaMode
try
{
var properties = FpgaSettingsParser.LoadSettings(project, fpgaModel.Fpga.Name);
+ var packTool = properties.GetValueOrDefault("yosysToolchainPackTool")
+ ?? throw new Exception("Pack Tool not set!");
+
+ var builder = toolExecutionDispatcherService.CreateToolCommandBuilder(packTool)
+ .WithWorkingDirectory(project.FullPath)
+ .WithStatus($"Running {packTool}...")
+ .WithTimer(true)
+ .WithErrorHandler(s =>
+ {
+ Dispatcher.UIThread.Post(() => { outputService.WriteLine(s); });
+ return true;
+ });
- var packTool = properties.GetValueOrDefault("yosysToolchainPackTool") ??
- throw new Exception("Pack Tool not set!");
-
- List packToolArguments = [];
-
- var cOutputType = properties
- .GetValueOrDefault("yosysToolchainOutputType", "asc");
+ var cOutputType = properties.GetValueOrDefault("yosysToolchainOutputType", "asc");
+ string inputPath = cOutputType switch
+ {
+ "asc" => "./build/nextpnr.asc",
+ "txt" => "./build/impl.txt",
+ _ => throw new ArgumentException($"Unsupported input type: {cOutputType}")
+ };
+ builder.AddPath(inputPath);
- switch (cOutputType)
+ var pOutputFormat = properties.GetValueOrDefault("packToolOutputFormat", "bin");
+ string outputPath = pOutputFormat switch
{
- case "asc":
- packToolArguments.Add("./build/nextpnr.asc");
- break;
- case "txt":
- packToolArguments.Add("./build/impl.txt");
- break;
- default:
- outputService.WriteLine($"Could not find input type: {cOutputType}");
- return false;
- }
+ "bin" => "./build/pack.bin",
+ "bit" => "./build/pack.bit",
+ _ => throw new ArgumentException($"Unsupported output format: {pOutputFormat}")
+ };
+ builder.AddPath(outputPath);
- var pOutputFormat = properties
- .GetValueOrDefault("packToolOutputFormat", "bin");
+ var flags = properties.GetValueOrDefault("yosysToolchainPackFlags")?.Split(' ',
+ StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) ?? [];
- switch (pOutputFormat)
+ foreach (var flag in flags)
{
- case "bin":
- packToolArguments.Add("./build/pack.bin");
- break;
- case "bit":
- packToolArguments.Add("./build/pack.bit");
- break;
- default:
- outputService.WriteLine($"Could not find output type: {pOutputFormat}");
- return false;
+ builder.Add(flag);
}
-
- packToolArguments.AddRange(properties.GetValueOrDefault("yosysToolchainPackFlags")?.Split(' ',
- StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) ?? []);
-
- var command = ToolCommand.FromShellParams(packTool, packToolArguments,
- project.FullPath, $"Running {packTool}...", AppState.Loading, true, null, s =>
- {
- Dispatcher.UIThread.Post(() => { outputService.WriteLine(s); });
- return true;
- });
+ var command = builder.Build();
var status = await toolExecutionDispatcherService.ExecuteAsync(command);
-
return status.success;
}
catch (Exception e)
{
logger.Error(e.Message, e);
+ outputService.WriteLine($"Error: {e.Message}");
return false;
}
}
@@ -301,11 +290,14 @@ public async Task AssembleAsync(UniversalFpgaProjectRoot project, FpgaMode
[Obsolete(message: "Use CreateJsonNetListAsync instead")]
public async Task CreateNetListJsonAsync(IProjectFile verilog)
{
- var command = ToolCommand.FromShellParams("yosys", [
- "-p", "hierarchy -auto-top; proc; opt; memory -nomap; wreduce -memx; opt_clean", "-o",
- $"{verilog.Header}.json", verilog.Header
- ],
- Path.GetDirectoryName(verilog.FullPath)!, $"Create Netlist...");
+ var command = toolExecutionDispatcherService.CreateToolCommandBuilder("yosys")
+ .WithWorkingDirectory(Path.GetDirectoryName(verilog.FullPath)!)
+ .WithStatus("Create Netlist...")
+ .Add("-p", "hierarchy -auto-top; proc; opt; memory -nomap; wreduce -memx; opt_clean")
+ .Add("-o")
+ .AddPath($"{verilog.Header}.json")
+ .AddPath(verilog.Header)
+ .Build();
await toolExecutionDispatcherService.ExecuteAsync(command);
}
@@ -315,9 +307,13 @@ public async Task> ExtractNodesAsync(IProjectFile file)
var buildpath = Path.Combine(file.Root.FullPath, "build");
Directory.CreateDirectory(buildpath);
- var command = ToolCommand.FromShellParams("yosys",
- ["-p", $"read_verilog {file.RelativePath}; proc; write_json build/yosys_nodes.json"],
- file.Root.FullPath, $"Running Yosys...", AppState.Loading, true);
+ var command = toolExecutionDispatcherService.CreateToolCommandBuilder("yosys")
+ .WithWorkingDirectory(file.Root.FullPath)
+ .WithStatus("Running Yosys...")
+ .WithTimer(true)
+ .Add("-p", $"read_verilog {file.RelativePath}; proc; write_json build/yosys_nodes.json")
+ .Build();
+
await toolExecutionDispatcherService.ExecuteAsync(command);
return ReadJson(Path.Combine(buildpath, "yosys_nodes.json"));
}
diff --git a/src/OneWare.ToolEngine/Arguments/CommandArgument.cs b/src/OneWare.ToolEngine/Arguments/CommandArgument.cs
new file mode 100644
index 000000000..8991ef6a8
--- /dev/null
+++ b/src/OneWare.ToolEngine/Arguments/CommandArgument.cs
@@ -0,0 +1,9 @@
+using System.Runtime.InteropServices;
+
+namespace OneWare.Essentials.ToolEngine;
+
+public class CommandArgument(string argument) : ICommandArgument
+{
+ public void Prepare(OSPlatform osPlatform, Func? pathMapper = null) {}
+ public string GetArgument() => argument;
+}
\ No newline at end of file
diff --git a/src/OneWare.ToolEngine/Arguments/PathArgument.cs b/src/OneWare.ToolEngine/Arguments/PathArgument.cs
new file mode 100644
index 000000000..4bbd2adb3
--- /dev/null
+++ b/src/OneWare.ToolEngine/Arguments/PathArgument.cs
@@ -0,0 +1,20 @@
+using System.Runtime.InteropServices;
+
+namespace OneWare.Essentials.ToolEngine;
+
+public class PathArgument(string path) : ICommandArgument
+{
+ private string _path = path;
+
+ public void Prepare(OSPlatform osPlatform, Func? pathMapper = null)
+ {
+ if (pathMapper != null) _path = pathMapper(_path);
+
+ if (osPlatform == OSPlatform.Windows)
+ _path = _path.Replace("/", "\\");
+ else
+ _path = _path.Replace("\\", "/");
+ }
+
+ public string GetArgument() => _path;
+}
\ No newline at end of file
diff --git a/src/OneWare.ToolEngine/Arguments/TemplateArgument.cs b/src/OneWare.ToolEngine/Arguments/TemplateArgument.cs
new file mode 100644
index 000000000..20aa83f1c
--- /dev/null
+++ b/src/OneWare.ToolEngine/Arguments/TemplateArgument.cs
@@ -0,0 +1,47 @@
+using System.Runtime.InteropServices;
+
+namespace OneWare.Essentials.ToolEngine;
+
+public class TemplateArgument : ICommandArgument
+{
+ private readonly string _template;
+ private readonly List<(string placeholder, string value, bool isPath)> _mappings = new();
+ private readonly Dictionary _resolvedValues = new();
+
+ public TemplateArgument(string template, params (string placeholder, string value, bool isPath)[] mappings)
+ {
+ _template = template;
+ foreach (var m in mappings)
+ {
+ _mappings.Add(m);
+ _resolvedValues[m.placeholder] = m.value;
+ }
+ }
+
+ public void Prepare(OSPlatform osPlatform, Func? pathMapper = null)
+ {
+ foreach (var (placeholder, value, isPath) in _mappings)
+ {
+ if (isPath)
+ {
+ var p = pathMapper != null ? pathMapper(value) : value;
+ p = osPlatform == OSPlatform.Windows ? p.Replace("/", "\\") : p.Replace("\\", "/");
+ _resolvedValues[placeholder] = p;
+ }
+ else
+ {
+ _resolvedValues[placeholder] = value;
+ }
+ }
+ }
+
+ public string GetArgument()
+ {
+ string result = _template;
+ foreach (var kvp in _resolvedValues)
+ {
+ result = result.Replace(kvp.Key, kvp.Value);
+ }
+ return result;
+ }
+}
\ No newline at end of file
diff --git a/src/OneWare.ToolEngine/OneWare.ToolEngine.csproj b/src/OneWare.ToolEngine/OneWare.ToolEngine.csproj
index c786bc734..5b9602aa8 100644
--- a/src/OneWare.ToolEngine/OneWare.ToolEngine.csproj
+++ b/src/OneWare.ToolEngine/OneWare.ToolEngine.csproj
@@ -8,6 +8,9 @@
+
+ <_Parameter1>OneWare.ToolEngine.UnitTests
+
diff --git a/src/OneWare.ToolEngine/Services/ToolCommandBuilder.cs b/src/OneWare.ToolEngine/Services/ToolCommandBuilder.cs
new file mode 100644
index 000000000..3b26ced48
--- /dev/null
+++ b/src/OneWare.ToolEngine/Services/ToolCommandBuilder.cs
@@ -0,0 +1,225 @@
+using System.Text.RegularExpressions;
+using OneWare.Essentials.Enums;
+using OneWare.Essentials.Services;
+using OneWare.Essentials.ToolEngine;
+
+namespace OneWare.ToolEngine.Services;
+
+public class ToolCommandBuilder : IToolCommandBuilder
+{
+ private readonly List _args = new();
+ private readonly Dictionary _envVars = new();
+ private readonly List _exposedPorts = new();
+ private readonly List _portMappings = new();
+ private readonly string _toolName;
+ private Func? _errorHandler;
+ private string? _executable;
+ private Func? _outputHandler;
+ private bool _showTimer;
+ private AppState _state = AppState.Loading;
+ private string _status = "Running...";
+ private string _workingDir = ".";
+
+ internal ToolCommandBuilder(string toolName)
+ {
+ _toolName = toolName;
+ }
+
+ public IToolCommandBuilder WithExecutable(string path)
+ {
+ _executable = path;
+ return this;
+ }
+
+ public IToolCommandBuilder AddRange(IEnumerable literals)
+ {
+ foreach (var lit in literals) Add(lit);
+ return this;
+ }
+
+ public IToolCommandBuilder AddPaths(IEnumerable paths)
+ {
+ foreach (var path in paths) AddPath(path);
+ return this;
+ }
+
+ public IToolCommandBuilder AddIf(bool condition, string literal)
+ {
+ if (condition) Add(literal);
+ return this;
+ }
+
+ public IToolCommandBuilder AddIfNotNull(string? literal)
+ {
+ if (!string.IsNullOrWhiteSpace(literal)) Add(literal);
+ return this;
+ }
+
+ public IToolCommandBuilder AddOption(string flag, string value)
+ {
+ Add(flag);
+ return Add(value);
+ }
+
+ public IToolCommandBuilder AddPathOption(string flag, string path)
+ {
+ Add(flag);
+ return AddPath(path);
+ }
+
+ public IToolCommandBuilder Add(string literal)
+ {
+ _args.Add(new CommandArgument(literal));
+ return this;
+ }
+
+ public IToolCommandBuilder Add(params string[] literals)
+ {
+ foreach (var lit in literals) Add(lit);
+ return this;
+ }
+
+ public IToolCommandBuilder AddPath(string path)
+ {
+ _args.Add(new PathArgument(path));
+ return this;
+ }
+
+ public IToolCommandBuilder AddScript(string template, params (string placeholder, string value)[] literals)
+ {
+ var mappings = literals.Select(x => (x.placeholder, x.value, isPath: false)).ToArray();
+ _args.Add(new TemplateArgument(template, mappings));
+ return this;
+ }
+
+ public IToolCommandBuilder AddScript(string template,
+ params (string placeholder, string value, bool isPath)[] mappings)
+ {
+ _args.Add(new TemplateArgument(template, mappings));
+ return this;
+ }
+
+ public IToolCommandBuilder WithWorkingDirectory(string dir)
+ {
+ _workingDir = dir;
+ return this;
+ }
+
+ public IToolCommandBuilder WithStatus(string status, AppState state = AppState.Loading)
+ {
+ _status = status;
+ _state = state;
+ return this;
+ }
+
+ public IToolCommandBuilder WithTimer(bool show)
+ {
+ _showTimer = show;
+ return this;
+ }
+
+ public IToolCommandBuilder WithOutputHandler(Func? handler)
+ {
+ _outputHandler = handler;
+ return this;
+ }
+
+ public IToolCommandBuilder WithErrorHandler(Func? handler)
+ {
+ _errorHandler = handler;
+ return this;
+ }
+
+ public IToolCommandBuilder AddRawArguments(string? rawArgs)
+ {
+ if (string.IsNullOrWhiteSpace(rawArgs)) return this;
+
+ var parts = Regex
+ .Matches(rawArgs, @"[\""].+?[\""]|[^ ]+")
+ .Select(m => m.Value.Trim('"'));
+
+ return AddRange(parts);
+ }
+
+ public IToolCommandBuilder AddOptionIfNotNull(string flag, string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value)) return this;
+
+ Add(flag);
+ Add(value);
+
+ return this;
+ }
+
+ public IToolCommandBuilder AddPathOptionIfNotNull(string flag, string? path)
+ {
+ if (string.IsNullOrWhiteSpace(path)) return this;
+
+ Add(flag);
+ AddPath(path);
+
+ return this;
+ }
+
+ public IToolCommandBuilder AddPathFromMap(TKey key, IDictionary map) where TKey : notnull
+ {
+ if (map.TryGetValue(key, out var path)) AddPath(path);
+ return this;
+ }
+
+ public IToolCommandBuilder WithEnvironmentVariable(string key, string value)
+ {
+ if (!string.IsNullOrWhiteSpace(key)) _envVars[key] = value;
+ return this;
+ }
+
+ public IToolCommandBuilder WithEnvironmentVariables(IDictionary variables)
+ {
+ foreach (var kvp in variables) WithEnvironmentVariable(kvp.Key, kvp.Value);
+ return this;
+ }
+
+ public IToolCommandBuilder WithEnvironmentVariableIf(bool condition, string key, string value)
+ {
+ return condition ? WithEnvironmentVariable(key, value) : this;
+ }
+
+ public IToolCommandBuilder WithExposedPort(int port, string protocol = "TCP")
+ {
+ if (!_exposedPorts.Any(p => p.Number == port && p.Protocol == protocol))
+ _exposedPorts.Add(new ToolPort(port, protocol.ToUpper()));
+ return this;
+ }
+
+ public IToolCommandBuilder AddPortMapping(int hostPort, int guestPort, string protocol = "TCP")
+ {
+ _portMappings.Add(new ToolPortMapping(
+ new ToolPort(hostPort, protocol.ToUpper()),
+ new ToolPort(guestPort, protocol.ToUpper())));
+ WithExposedPort(guestPort, protocol);
+
+ return this;
+ }
+
+ public ToolCommand Build()
+ {
+ if (string.IsNullOrWhiteSpace(_toolName) && string.IsNullOrWhiteSpace(_executable))
+ throw new InvalidOperationException("Tool name or executable must be set.");
+
+ return new ToolCommand
+ {
+ ToolName = _toolName,
+ Executable = _executable ?? _toolName,
+ CommandArguments = _args.AsReadOnly(),
+ WorkingDirectory = _workingDir,
+ StatusMessage = _status,
+ State = _state,
+ ShowTimer = _showTimer,
+ OutputHandler = _outputHandler,
+ ErrorHandler = _errorHandler,
+ EnvironmentVariables = new Dictionary(_envVars),
+ PortMappings = _portMappings,
+ ExposedPorts = _exposedPorts
+ };
+ }
+}
\ No newline at end of file
diff --git a/src/OneWare.ToolEngine/Services/ToolExecutionDispatcherService.cs b/src/OneWare.ToolEngine/Services/ToolExecutionDispatcherService.cs
index 7fbbdc3b9..95d1ed2f7 100644
--- a/src/OneWare.ToolEngine/Services/ToolExecutionDispatcherService.cs
+++ b/src/OneWare.ToolEngine/Services/ToolExecutionDispatcherService.cs
@@ -1,12 +1,45 @@
+using System.Diagnostics;
+using Microsoft.Extensions.Logging;
using OneWare.Essentials.Services;
using OneWare.Essentials.ToolEngine;
namespace OneWare.ToolEngine.Services;
-public class ToolExecutionDispatcherService(IToolService service) : IToolExecutionDispatcherService
+public class ToolExecutionDispatcherService(IToolService service, ILogger logger) : IToolExecutionDispatcherService
{
public Task<(bool success, string output)> ExecuteAsync(ToolCommand command)
{
- return service.GetStrategy(command.ToolName).ExecuteAsync(command);
+ try
+ {
+ return service.GetStrategy(command.ToolName).ExecuteAsync(command);
+ }
+ catch (InvalidOperationException exception)
+ {
+ logger.LogError(exception, exception.Message);
+ }
+
+ return Task.FromResult<(bool success, string output)>((false, ""));
+
+ }
+
+ public WeakReference StartWeakProcess(ToolCommand command)
+ {
+ try
+ {
+ return service.GetStrategy(command.ToolName).StartWeakProcess(command);
+ }
+ catch (InvalidOperationException exception)
+ {
+ logger.LogError(exception, exception.Message);
+ }
+
+ return null!;
+ }
+
+ public IToolCommandBuilder CreateToolCommandBuilder(string toolName)
+ {
+ // This is just in case you want to access a different service in the Builder in the future.
+ // Such as the outputService or similar.
+ return new ToolCommandBuilder(toolName);
}
}
\ No newline at end of file
diff --git a/src/OneWare.ToolEngine/Services/ToolService.cs b/src/OneWare.ToolEngine/Services/ToolService.cs
index d5ae394f6..9d4b327ea 100644
--- a/src/OneWare.ToolEngine/Services/ToolService.cs
+++ b/src/OneWare.ToolEngine/Services/ToolService.cs
@@ -3,6 +3,7 @@
using OneWare.Essentials.Models;
using OneWare.Essentials.Services;
using OneWare.Essentials.ToolEngine;
+using OneWare.Essentials.ToolEngine.Strategies;
namespace OneWare.ToolEngine.Services;
@@ -93,13 +94,24 @@ public IReadOnlyList GetStrategies(string toolKey)
public IToolExecutionStrategy GetStrategy(string toolKey)
{
+ if (!_settingsService.HasSetting(toolKey))
+ {
+ throw new InvalidOperationException(
+ $"No Setting for key '{toolKey}' was found. Register Tool first bevor you are using it");
+ }
+
var strategyKey = _settingsService.GetSettingValue(toolKey);
if (_toolStrategies.TryGetValue(toolKey, out var strategies) &&
strategies.TryGetValue(strategyKey, out var strategy))
return strategy;
-
- throw new InvalidOperationException(
- $"No execution strategy found for tool '{toolKey}' and strategy '{strategyKey}'");
+
+ _logger.LogError($"No execution strategy found for tool '{toolKey}' and strategy '{strategyKey}'");
+ _logger.LogError("Using default strategy");
+
+ if (strategies != null && strategies.TryGetValue(NativeStrategy.ToolKey, out var defaultStrategy))
+ return defaultStrategy;
+
+ throw new InvalidOperationException($"No strategy with key '{toolKey}' was found.");
}
private void RegisterToolInSettings(ToolContext description)
diff --git a/tests/OneWare.Essentials.UnitTests/OneWare.Essentials.UnitTests.csproj b/tests/OneWare.Essentials.UnitTests/OneWare.Essentials.UnitTests.csproj
index 22a1d9145..dcd8f3adc 100644
--- a/tests/OneWare.Essentials.UnitTests/OneWare.Essentials.UnitTests.csproj
+++ b/tests/OneWare.Essentials.UnitTests/OneWare.Essentials.UnitTests.csproj
@@ -16,4 +16,8 @@
+
+
+
+
diff --git a/tests/OneWare.ToolEngine.UnitTests/OneWare.ToolEngine.UnitTests.csproj b/tests/OneWare.ToolEngine.UnitTests/OneWare.ToolEngine.UnitTests.csproj
new file mode 100644
index 000000000..a7c216cd9
--- /dev/null
+++ b/tests/OneWare.ToolEngine.UnitTests/OneWare.ToolEngine.UnitTests.csproj
@@ -0,0 +1,23 @@
+
+
+
+
+
+ net10.0
+ Library
+ False
+ True
+ False
+ True
+ enable
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/OneWare.ToolEngine.UnitTests/ToolEngine/PathArgumentTests.cs b/tests/OneWare.ToolEngine.UnitTests/ToolEngine/PathArgumentTests.cs
new file mode 100644
index 000000000..80677785b
--- /dev/null
+++ b/tests/OneWare.ToolEngine.UnitTests/ToolEngine/PathArgumentTests.cs
@@ -0,0 +1,58 @@
+using System.Runtime.InteropServices;
+using OneWare.Essentials.ToolEngine;
+using Xunit;
+
+namespace OneWare.ToolEngine.UnitTests.ToolEngine;
+
+public class PathArgumentTests
+{
+ [Theory]
+ [InlineData("folder/subfolder/file.txt", @"folder\subfolder\file.txt")]
+ [InlineData("C:/Data/Test", @"C:\Data\Test")]
+ public void Prepare_ShouldConvertToWindowsBackslashes(string input, string expected)
+ {
+ var arg = new PathArgument(input);
+
+ arg.Prepare(OSPlatform.Windows);
+ var result = arg.GetArgument();
+
+ Assert.Equal(expected, result);
+ }
+
+ [Theory]
+ [InlineData(@"folder\subfolder\file.txt", "folder/subfolder/file.txt")]
+ [InlineData("/home/user/test", "/home/user/test")]
+ public void Prepare_ShouldConvertToLinuxForwardSlashes(string input, string expected)
+ {
+ var arg = new PathArgument(input);
+
+ arg.Prepare(OSPlatform.Linux);
+ var result = arg.GetArgument();
+
+ Assert.Equal(expected, result);
+ }
+
+ [Fact]
+ public void Prepare_WithMapper_ShouldApplyTransformationBeforeNormalization()
+ {
+ const string initialPath = "relative/path";
+ var arg = new PathArgument(initialPath);
+
+ arg.Prepare(OSPlatform.Windows, Mapper);
+ Assert.Equal(@"\abs\root\relative\path", arg.GetArgument());
+ return;
+
+ string Mapper(string p) => $"/abs/root/{p}";
+ }
+
+ [Fact]
+ public void GetArgument_WithoutPrepare_ShouldReturnInitialPath()
+ {
+ const string initialPath = "some/path/here";
+ var arg = new PathArgument(initialPath);
+
+ var result = arg.GetArgument();
+
+ Assert.Equal(initialPath, result);
+ }
+}
\ No newline at end of file
diff --git a/tests/OneWare.ToolEngine.UnitTests/ToolEngine/TemplateArgumentTests.cs b/tests/OneWare.ToolEngine.UnitTests/ToolEngine/TemplateArgumentTests.cs
new file mode 100644
index 000000000..c681cf99b
--- /dev/null
+++ b/tests/OneWare.ToolEngine.UnitTests/ToolEngine/TemplateArgumentTests.cs
@@ -0,0 +1,69 @@
+using System;
+
+namespace OneWare.ToolEngine.UnitTests.ToolEngine;
+
+using System.Runtime.InteropServices;
+using OneWare.Essentials.ToolEngine;
+using Xunit;
+
+
+public class TemplateArgumentTests
+{
+ [Fact]
+ public void GetArgument_ShouldReplacePlaceholders()
+ {
+ const string template = "gcc -o OUTPUT INPUT";
+ var mappings = new[]
+ {
+ ("OUTPUT", "main.exe", false),
+ ("INPUT", "main.c", false)
+ };
+ var arg = new TemplateArgument(template, mappings);
+ arg.Prepare(OSPlatform.Windows);
+
+ var result = arg.GetArgument();
+
+ Assert.Equal("gcc -o main.exe main.c", result);
+ }
+
+ [Theory]
+ [InlineData("C:/Users/Test", @"C:\Users\Test")]
+ public void Prepare_ShouldAdjustPathsForWindows(string inputPath, string expectedPath)
+ {
+ const string template = "path: PATH";
+ var arg = new TemplateArgument(template, ("PATH", inputPath, true));
+
+ arg.Prepare(OSPlatform.Windows);
+ var result = arg.GetArgument();
+
+ Assert.Contains(expectedPath, result);
+ }
+
+ [Theory]
+ [InlineData(@"C:\Users\Test", "C:/Users/Test")]
+ public void Prepare_ShouldAdjustPathsForLinux(string inputPath, string expectedPath)
+ {
+ const string template = "path: PATH";
+ var arg = new TemplateArgument(template, ("PATH", inputPath, true));
+
+ arg.Prepare(OSPlatform.Linux);
+ var result = arg.GetArgument();
+
+ Assert.Contains(expectedPath, result);
+ }
+
+ [Fact]
+ public void Prepare_WithPathMapper_ShouldTransformValue()
+ {
+ const string template = "file: FILE";
+ var arg = new TemplateArgument(template, ("FILE", "relative/path", true));
+
+ arg.Prepare(OSPlatform.Linux, Mapper);
+ var result = arg.GetArgument();
+
+ Assert.Equal("file: /base/relative/path", result);
+ return;
+
+ string Mapper(string p) => $"/base/{p}";
+ }
+}
\ No newline at end of file
diff --git a/tests/OneWare.ToolEngine.UnitTests/ToolEngine/ToolCommandBuilderTests.cs b/tests/OneWare.ToolEngine.UnitTests/ToolEngine/ToolCommandBuilderTests.cs
new file mode 100644
index 000000000..0397b5eeb
--- /dev/null
+++ b/tests/OneWare.ToolEngine.UnitTests/ToolEngine/ToolCommandBuilderTests.cs
@@ -0,0 +1,140 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using OneWare.Essentials.Enums;
+using OneWare.Essentials.ToolEngine;
+using OneWare.ToolEngine.Services;
+using Xunit;
+
+namespace OneWare.ToolEngine.UnitTests.ToolEngine;
+
+public class ToolCommandBuilderTests
+{
+ private const string DefaultTool = "gcc";
+
+ [Fact]
+ public void Build_MinimumSetup_ReturnsCorrectCommand()
+ {
+ var command = new ToolCommandBuilder(DefaultTool).Build();
+
+ Assert.Equal(DefaultTool, command.ToolName);
+ Assert.Equal(DefaultTool, command.Executable);
+ Assert.Empty(command.CommandArguments);
+ Assert.Equal(".", command.WorkingDirectory);
+ }
+
+ [Fact]
+ public void Build_WithoutNameOrExecutable_ThrowsException()
+ {
+ var builder = new ToolCommandBuilder("");
+
+ Assert.Throws(builder.Build);
+ }
+
+ [Fact]
+ public void WithMethods_SetCorrectProperties()
+ {
+ var outputHandler = (string s) => true;
+ var errorHandler = (string s) => false;
+
+ var command = new ToolCommandBuilder(DefaultTool)
+ .WithExecutable("/usr/bin/gcc")
+ .WithWorkingDirectory("/temp")
+ .WithStatus("Compiling...", AppState.Idle)
+ .WithTimer(true)
+ .WithOutputHandler(outputHandler)
+ .WithErrorHandler(errorHandler)
+ .Build();
+
+ Assert.Equal("/usr/bin/gcc", command.Executable);
+ Assert.Equal("/temp", command.WorkingDirectory);
+ Assert.Equal("Compiling...", command.StatusMessage);
+ Assert.Equal(AppState.Idle, command.State);
+ Assert.True(command.ShowTimer);
+ Assert.Equal(outputHandler, command.OutputHandler);
+ Assert.Equal(errorHandler, command.ErrorHandler);
+ }
+
+ [Fact]
+ public void AddMethods_StoreCorrectArgumentTypes()
+ {
+ var command = new ToolCommandBuilder(DefaultTool)
+ .Add("-v")
+ .AddPath("/src/main.c")
+ .AddOption("--level", "3")
+ .AddPathOption("-o", "/bin/out")
+ .Build();
+
+ var args = command.CommandArguments.ToList();
+ Assert.Equal(6, args.Count);
+ Assert.IsType(args[0]);
+ Assert.IsType(args[1]);
+ Assert.IsType(args[2]);
+ Assert.IsType(args[3]);
+ Assert.IsType(args[4]);
+ Assert.IsType(args[5]);
+ }
+
+ [Fact]
+ public void ConditionalAdds_WorkCorrectly()
+ {
+ var command = new ToolCommandBuilder(DefaultTool)
+ .AddIf(true, "--enabled")
+ .AddIf(false, "--disabled")
+ .AddIfNotNull("valid")
+ .AddIfNotNull(null)
+ .AddIfNotNull(" ")
+ .Build();
+
+ Assert.Equal(2, command.Arguments.Count);
+ Assert.Contains("--enabled", command.Arguments);
+ Assert.Contains("valid", command.Arguments);
+ Assert.DoesNotContain("--disabled", command.Arguments);
+ }
+
+ [Fact]
+ public void AddRawArguments_ParsesCorrectWithQuotes()
+ {
+ const string raw = "--opt1 \"C:\\Path With Spaces\\file.txt\" --opt2 simpleValue";
+
+ var command = new ToolCommandBuilder(DefaultTool)
+ .AddRawArguments(raw)
+ .Build();
+
+ var args = command.Arguments.ToList();
+ Assert.Equal(4, args.Count);
+ Assert.Equal("--opt1", args[0]);
+ Assert.Equal(@"C:\Path With Spaces\file.txt", args[1]);
+ Assert.Equal("--opt2", args[2]);
+ Assert.Equal("simpleValue", args[3]);
+ }
+
+ [Fact]
+ public void AddScript_CreatesTemplateArgument()
+ {
+ var command = new ToolCommandBuilder(DefaultTool)
+ .AddScript("echo PLACEHOLDER", ("PLACEHOLDER", "Hello"))
+ .Build();
+
+ var arg = Assert.Single(command.CommandArguments);
+ Assert.IsType(arg);
+ Assert.Equal("echo Hello", command.Arguments.First());
+ }
+
+ [Fact]
+ public void AddRangeAndAddPaths_WorkForEnumerables()
+ {
+
+ var lits = new List { "a", "b" };
+ var paths = new List { "/p1", "/p2" };
+
+ var command = new ToolCommandBuilder(DefaultTool)
+ .AddRange(lits)
+ .AddPaths(paths)
+ .Build();
+
+ Assert.Equal(4, command.Arguments.Count);
+ Assert.IsType(command.CommandArguments.First());
+ Assert.IsType(command.CommandArguments.Last());
+ }
+}
\ No newline at end of file
diff --git a/tests/OneWare.ToolEngine.UnitTests/ToolEngine/ToolCommandTests.cs b/tests/OneWare.ToolEngine.UnitTests/ToolEngine/ToolCommandTests.cs
new file mode 100644
index 000000000..29f8618d9
--- /dev/null
+++ b/tests/OneWare.ToolEngine.UnitTests/ToolEngine/ToolCommandTests.cs
@@ -0,0 +1,114 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.InteropServices;
+using OneWare.Essentials.ToolEngine;
+using OneWare.ToolEngine.Services;
+using Xunit;
+
+namespace OneWare.ToolEngine.UnitTests.ToolEngine;
+
+public class ToolCommandTests
+{
+ [Fact]
+ public void PrepareCommand_ShouldInvokePrepareOnAllArguments()
+ {
+ var pathArg = new PathArgument("folder/file.txt");
+ var tempArg = new TemplateArgument("echo T", ("T", "val", false));
+
+ var command = new ToolCommand
+ {
+ ToolName = "Test",
+ CommandArguments = new List { pathArg, tempArg }
+ };
+
+ command.PrepareCommand(OSPlatform.Windows);
+
+ var resultArgs = command.Arguments.ToList();
+ Assert.Equal("folder\\file.txt", resultArgs[0]);
+ Assert.Equal("echo val", resultArgs[1]);
+ }
+
+ [Fact]
+ public void ArgumentsProperty_ShouldReflectCurrentStateOfCommandArguments()
+ {
+ var arg = new PathArgument("a/b");
+ var command = new ToolCommand
+ {
+ ToolName = "Test",
+ CommandArguments = new List { arg }
+ };
+
+ Assert.Equal("a/b", command.Arguments.First());
+
+ command.PrepareCommand(OSPlatform.Windows);
+
+ Assert.Equal("a\\b", command.Arguments.First());
+ }
+
+ ///
+ /// Verifies that local host paths are correctly mapped to container/remote paths.
+ ///
+ [Fact]
+ public void PrepareCommand_ChangePaths()
+ {
+ var command = new ToolCommandBuilder("test")
+ .Add("-k")
+ .AddIf(false, "notPresent")
+ .AddIf(true, "Present")
+ .AddPath(@"C:\Users\User\OneWareStudio\Projects\Example\Example.v")
+ .AddPath("folder/file.txt")
+ .WithWorkingDirectory("/home/user")
+ .AddPathOption("-o", "folder2/file.txt")
+ .AddPaths(["folder/file2.txt", "folder2/file3.txt"])
+ .Build();
+
+
+ const string localProjectRoot = @"C:\Users\User\OneWareStudio\Projects\Example";
+ const string remoteWorkDir = "/home/user/Example";
+
+
+ command.PrepareCommand(OSPlatform.Linux, ContainerMapper);
+
+ var resultArgs = command.Arguments.ToList();
+ Assert.Equal("-k", resultArgs[0]);
+ Assert.Equal("Present", resultArgs[1]);
+ Assert.Equal("/home/user/Example/Example.v", resultArgs[2]);
+ Assert.Equal("/home/user/Example/folder/file.txt", resultArgs[3]);
+ Assert.Equal("-o", resultArgs[4]);
+ Assert.Equal("/home/user/Example/folder2/file.txt", resultArgs[5]);
+ Assert.Equal("/home/user/Example/folder/file2.txt", resultArgs[6]);
+ Assert.Equal("/home/user/Example/folder2/file3.txt", resultArgs[7]);
+ return;
+
+
+ string ContainerMapper(string inputPath)
+ {
+ if (string.IsNullOrEmpty(inputPath)) return inputPath;
+
+ var normalizedInput = inputPath.Replace('\\', '/');
+ var normalizedRoot = localProjectRoot.Replace('\\', '/');
+
+ string fullPath;
+
+ if (normalizedInput.StartsWith('/') || (normalizedInput.Length > 1 && normalizedInput[1] == ':'))
+ {
+ fullPath = normalizedInput;
+ }
+ else
+ {
+ fullPath = $"{normalizedRoot.TrimEnd('/')}/{normalizedInput.TrimStart('/')}";
+ }
+
+ if (!fullPath.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase))
+ return fullPath;
+
+ var relativePart = fullPath[normalizedRoot.Length..].TrimStart('/');
+
+ return string.IsNullOrEmpty(relativePart)
+ ? remoteWorkDir
+ : $"{remoteWorkDir}/{relativePart}";
+
+ }
+ }
+}
\ No newline at end of file