editing jobs

This commit is contained in:
Francesco Renzi
2026-01-21 22:30:19 +00:00
committed by GitHub
parent 9bc9aff86f
commit 008594a3ee
14 changed files with 6450 additions and 9 deletions

View File

@@ -0,0 +1,930 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using GitHub.Runner.Common;
using Newtonsoft.Json.Linq;
namespace GitHub.Runner.Worker.Dap.StepCommands
{
/// <summary>
/// Interface for parsing step commands from REPL strings or JSON.
/// </summary>
[ServiceLocator(Default = typeof(StepCommandParser))]
public interface IStepCommandParser : IRunnerService
{
/// <summary>
/// Parses a command string (REPL or JSON) into a structured StepCommand.
/// </summary>
/// <param name="input">The input string (e.g., "!step list --verbose" or JSON)</param>
/// <returns>Parsed StepCommand</returns>
/// <exception cref="StepCommandException">If parsing fails</exception>
StepCommand Parse(string input);
/// <summary>
/// Checks if the input is a step command.
/// </summary>
/// <param name="input">The input string to check</param>
/// <returns>True if this is a step command (REPL or JSON format)</returns>
bool IsStepCommand(string input);
}
#region Command Classes
/// <summary>
/// Base class for all step commands.
/// </summary>
public abstract class StepCommand
{
/// <summary>
/// Whether the original input was JSON (affects response format).
/// </summary>
public bool WasJsonInput { get; set; }
}
/// <summary>
/// !step list [--verbose]
/// </summary>
public class ListCommand : StepCommand
{
public bool Verbose { get; set; }
}
/// <summary>
/// !step add run "script" [options]
/// </summary>
public class AddRunCommand : StepCommand
{
public string Script { get; set; }
public string Name { get; set; }
public string Shell { get; set; }
public string WorkingDirectory { get; set; }
public Dictionary<string, string> Env { get; set; }
public string Condition { get; set; }
public bool ContinueOnError { get; set; }
public int? Timeout { get; set; }
public StepPosition Position { get; set; } = StepPosition.Last();
}
/// <summary>
/// !step add uses "action@ref" [options]
/// </summary>
public class AddUsesCommand : StepCommand
{
public string Action { get; set; }
public string Name { get; set; }
public Dictionary<string, string> With { get; set; }
public Dictionary<string, string> Env { get; set; }
public string Condition { get; set; }
public bool ContinueOnError { get; set; }
public int? Timeout { get; set; }
public StepPosition Position { get; set; } = StepPosition.Last();
}
/// <summary>
/// !step edit <index> [modifications]
/// </summary>
public class EditCommand : StepCommand
{
public int Index { get; set; }
public string Name { get; set; }
public string Script { get; set; }
public string Action { get; set; }
public string Shell { get; set; }
public string WorkingDirectory { get; set; }
public string Condition { get; set; }
public Dictionary<string, string> With { get; set; }
public Dictionary<string, string> Env { get; set; }
public List<string> RemoveWith { get; set; }
public List<string> RemoveEnv { get; set; }
public bool? ContinueOnError { get; set; }
public int? Timeout { get; set; }
}
/// <summary>
/// !step remove <index>
/// </summary>
public class RemoveCommand : StepCommand
{
public int Index { get; set; }
}
/// <summary>
/// !step move <from> [position options]
/// </summary>
public class MoveCommand : StepCommand
{
public int FromIndex { get; set; }
public StepPosition Position { get; set; }
}
/// <summary>
/// !step export [--changes-only] [--with-comments]
/// </summary>
public class ExportCommand : StepCommand
{
public bool ChangesOnly { get; set; }
public bool WithComments { get; set; }
}
#endregion
#region Position Types
/// <summary>
/// Types of position specifications for inserting/moving steps.
/// </summary>
public enum PositionType
{
/// <summary>Insert at specific index (1-based)</summary>
At,
/// <summary>Insert after specific index (1-based)</summary>
After,
/// <summary>Insert before specific index (1-based)</summary>
Before,
/// <summary>Insert at first pending position</summary>
First,
/// <summary>Insert at end (default)</summary>
Last
}
/// <summary>
/// Represents a position for inserting or moving steps.
/// </summary>
public class StepPosition
{
public PositionType Type { get; set; }
public int? Index { get; set; }
public static StepPosition At(int index) => new StepPosition { Type = PositionType.At, Index = index };
public static StepPosition After(int index) => new StepPosition { Type = PositionType.After, Index = index };
public static StepPosition Before(int index) => new StepPosition { Type = PositionType.Before, Index = index };
public static StepPosition First() => new StepPosition { Type = PositionType.First };
public static StepPosition Last() => new StepPosition { Type = PositionType.Last };
public override string ToString()
{
return Type switch
{
PositionType.At => $"at {Index}",
PositionType.After => $"after {Index}",
PositionType.Before => $"before {Index}",
PositionType.First => "first",
PositionType.Last => "last",
_ => "unknown"
};
}
}
#endregion
/// <summary>
/// Parser implementation for step commands (REPL and JSON formats).
/// </summary>
public sealed class StepCommandParser : RunnerService, IStepCommandParser
{
// Regex to match quoted strings (handles escaped quotes)
private static readonly Regex QuotedStringRegex = new Regex(
@"""(?:[^""\\]|\\.)*""|'(?:[^'\\]|\\.)*'",
RegexOptions.Compiled);
public bool IsStepCommand(string input)
{
if (string.IsNullOrWhiteSpace(input))
return false;
var trimmed = input.Trim();
// REPL command format: !step ...
if (trimmed.StartsWith("!step", StringComparison.OrdinalIgnoreCase))
return true;
// JSON format: {"cmd": "step.*", ...}
if (trimmed.StartsWith("{") && trimmed.Contains("\"cmd\"") && trimmed.Contains("\"step."))
return true;
return false;
}
public StepCommand Parse(string input)
{
var trimmed = input?.Trim() ?? "";
if (trimmed.StartsWith("{"))
{
return ParseJsonCommand(trimmed);
}
else
{
return ParseReplCommand(trimmed);
}
}
#region JSON Parsing
private StepCommand ParseJsonCommand(string json)
{
JObject obj;
try
{
obj = JObject.Parse(json);
}
catch (Exception ex)
{
throw new StepCommandException(StepCommandErrors.ParseError, $"Invalid JSON: {ex.Message}");
}
var cmd = obj["cmd"]?.ToString();
if (string.IsNullOrEmpty(cmd))
{
throw new StepCommandException(StepCommandErrors.ParseError, "Missing 'cmd' field in JSON");
}
StepCommand result = cmd switch
{
"step.list" => ParseJsonListCommand(obj),
"step.add" => ParseJsonAddCommand(obj),
"step.edit" => ParseJsonEditCommand(obj),
"step.remove" => ParseJsonRemoveCommand(obj),
"step.move" => ParseJsonMoveCommand(obj),
"step.export" => ParseJsonExportCommand(obj),
_ => throw new StepCommandException(StepCommandErrors.InvalidCommand, $"Unknown command: {cmd}")
};
result.WasJsonInput = true;
return result;
}
private ListCommand ParseJsonListCommand(JObject obj)
{
return new ListCommand
{
Verbose = obj["verbose"]?.Value<bool>() ?? false
};
}
private StepCommand ParseJsonAddCommand(JObject obj)
{
var type = obj["type"]?.ToString()?.ToLower();
if (type == "run")
{
var script = obj["script"]?.ToString();
if (string.IsNullOrEmpty(script))
{
throw new StepCommandException(StepCommandErrors.ParseError, "Missing 'script' field for run step");
}
return new AddRunCommand
{
Script = script,
Name = obj["name"]?.ToString(),
Shell = obj["shell"]?.ToString(),
WorkingDirectory = obj["workingDirectory"]?.ToString(),
Env = ParseJsonDictionary(obj["env"]),
Condition = obj["if"]?.ToString(),
ContinueOnError = obj["continueOnError"]?.Value<bool>() ?? false,
Timeout = obj["timeout"]?.Value<int>(),
Position = ParseJsonPosition(obj["position"])
};
}
else if (type == "uses")
{
var action = obj["action"]?.ToString();
if (string.IsNullOrEmpty(action))
{
throw new StepCommandException(StepCommandErrors.ParseError, "Missing 'action' field for uses step");
}
return new AddUsesCommand
{
Action = action,
Name = obj["name"]?.ToString(),
With = ParseJsonDictionary(obj["with"]),
Env = ParseJsonDictionary(obj["env"]),
Condition = obj["if"]?.ToString(),
ContinueOnError = obj["continueOnError"]?.Value<bool>() ?? false,
Timeout = obj["timeout"]?.Value<int>(),
Position = ParseJsonPosition(obj["position"])
};
}
else
{
throw new StepCommandException(StepCommandErrors.InvalidType,
$"Invalid step type: '{type}'. Must be 'run' or 'uses'.");
}
}
private EditCommand ParseJsonEditCommand(JObject obj)
{
var index = obj["index"]?.Value<int>();
if (!index.HasValue)
{
throw new StepCommandException(StepCommandErrors.ParseError, "Missing 'index' field for edit command");
}
return new EditCommand
{
Index = index.Value,
Name = obj["name"]?.ToString(),
Script = obj["script"]?.ToString(),
Action = obj["action"]?.ToString(),
Shell = obj["shell"]?.ToString(),
WorkingDirectory = obj["workingDirectory"]?.ToString(),
Condition = obj["if"]?.ToString(),
With = ParseJsonDictionary(obj["with"]),
Env = ParseJsonDictionary(obj["env"]),
RemoveWith = ParseJsonStringList(obj["removeWith"]),
RemoveEnv = ParseJsonStringList(obj["removeEnv"]),
ContinueOnError = obj["continueOnError"]?.Value<bool>(),
Timeout = obj["timeout"]?.Value<int>()
};
}
private RemoveCommand ParseJsonRemoveCommand(JObject obj)
{
var index = obj["index"]?.Value<int>();
if (!index.HasValue)
{
throw new StepCommandException(StepCommandErrors.ParseError, "Missing 'index' field for remove command");
}
return new RemoveCommand { Index = index.Value };
}
private MoveCommand ParseJsonMoveCommand(JObject obj)
{
var from = obj["from"]?.Value<int>();
if (!from.HasValue)
{
throw new StepCommandException(StepCommandErrors.ParseError, "Missing 'from' field for move command");
}
var position = ParseJsonPosition(obj["position"]);
if (position.Type == PositionType.Last)
{
// Default 'last' is fine for add, but move needs explicit position
// unless explicitly set
var posObj = obj["position"];
if (posObj == null)
{
throw new StepCommandException(StepCommandErrors.ParseError, "Missing 'position' field for move command");
}
}
return new MoveCommand
{
FromIndex = from.Value,
Position = position
};
}
private ExportCommand ParseJsonExportCommand(JObject obj)
{
return new ExportCommand
{
ChangesOnly = obj["changesOnly"]?.Value<bool>() ?? false,
WithComments = obj["withComments"]?.Value<bool>() ?? false
};
}
private StepPosition ParseJsonPosition(JToken token)
{
if (token == null || token.Type == JTokenType.Null)
return StepPosition.Last();
if (token.Type == JTokenType.Object)
{
var obj = (JObject)token;
if (obj["at"] != null)
return StepPosition.At(obj["at"].Value<int>());
if (obj["after"] != null)
return StepPosition.After(obj["after"].Value<int>());
if (obj["before"] != null)
return StepPosition.Before(obj["before"].Value<int>());
if (obj["first"]?.Value<bool>() == true)
return StepPosition.First();
if (obj["last"]?.Value<bool>() == true)
return StepPosition.Last();
}
return StepPosition.Last();
}
private Dictionary<string, string> ParseJsonDictionary(JToken token)
{
if (token == null || token.Type != JTokenType.Object)
return null;
var result = new Dictionary<string, string>();
foreach (var prop in ((JObject)token).Properties())
{
result[prop.Name] = prop.Value?.ToString() ?? "";
}
return result.Count > 0 ? result : null;
}
private List<string> ParseJsonStringList(JToken token)
{
if (token == null || token.Type != JTokenType.Array)
return null;
var result = new List<string>();
foreach (var item in (JArray)token)
{
var str = item?.ToString();
if (!string.IsNullOrEmpty(str))
result.Add(str);
}
return result.Count > 0 ? result : null;
}
#endregion
#region REPL Parsing
private StepCommand ParseReplCommand(string input)
{
// Tokenize the input, respecting quoted strings
var tokens = Tokenize(input);
if (tokens.Count < 2 || !tokens[0].Equals("!step", StringComparison.OrdinalIgnoreCase))
{
throw new StepCommandException(StepCommandErrors.ParseError,
"Invalid command format. Expected: !step <command> [args...]");
}
var subCommand = tokens[1].ToLower();
return subCommand switch
{
"list" => ParseReplListCommand(tokens),
"add" => ParseReplAddCommand(tokens),
"edit" => ParseReplEditCommand(tokens),
"remove" => ParseReplRemoveCommand(tokens),
"move" => ParseReplMoveCommand(tokens),
"export" => ParseReplExportCommand(tokens),
_ => throw new StepCommandException(StepCommandErrors.InvalidCommand, $"Unknown sub-command: {subCommand}")
};
}
private List<string> Tokenize(string input)
{
var tokens = new List<string>();
var remaining = input;
while (!string.IsNullOrEmpty(remaining))
{
remaining = remaining.TrimStart();
if (string.IsNullOrEmpty(remaining))
break;
// Check for quoted string
var match = QuotedStringRegex.Match(remaining);
if (match.Success && match.Index == 0)
{
// Extract the quoted content (without quotes)
var quoted = match.Value;
var content = quoted.Substring(1, quoted.Length - 2);
// Unescape
content = content.Replace("\\\"", "\"").Replace("\\'", "'").Replace("\\\\", "\\");
tokens.Add(content);
remaining = remaining.Substring(match.Length);
}
else
{
// Non-quoted token
var spaceIndex = remaining.IndexOfAny(new[] { ' ', '\t' });
if (spaceIndex == -1)
{
tokens.Add(remaining);
break;
}
tokens.Add(remaining.Substring(0, spaceIndex));
remaining = remaining.Substring(spaceIndex);
}
}
return tokens;
}
private ListCommand ParseReplListCommand(List<string> tokens)
{
var cmd = new ListCommand();
for (int i = 2; i < tokens.Count; i++)
{
var token = tokens[i].ToLower();
if (token == "--verbose" || token == "-v")
{
cmd.Verbose = true;
}
else
{
throw new StepCommandException(StepCommandErrors.InvalidOption,
$"Unknown option for list: {tokens[i]}");
}
}
return cmd;
}
private StepCommand ParseReplAddCommand(List<string> tokens)
{
if (tokens.Count < 3)
{
throw new StepCommandException(StepCommandErrors.ParseError,
"Usage: !step add <run|uses> <script|action> [options]");
}
var type = tokens[2].ToLower();
if (type == "run")
{
return ParseReplAddRunCommand(tokens);
}
else if (type == "uses")
{
return ParseReplAddUsesCommand(tokens);
}
else
{
throw new StepCommandException(StepCommandErrors.InvalidType,
$"Invalid step type: '{type}'. Must be 'run' or 'uses'.");
}
}
private AddRunCommand ParseReplAddRunCommand(List<string> tokens)
{
// !step add run "script" [options]
if (tokens.Count < 4)
{
throw new StepCommandException(StepCommandErrors.ParseError,
"Usage: !step add run \"<script>\" [--name \"...\"] [--shell <shell>] [--at|--after|--before <n>]");
}
var cmd = new AddRunCommand
{
Script = tokens[3],
Env = new Dictionary<string, string>()
};
// Parse options
for (int i = 4; i < tokens.Count; i++)
{
var opt = tokens[i].ToLower();
switch (opt)
{
case "--name":
cmd.Name = GetNextArg(tokens, ref i, "--name");
break;
case "--shell":
cmd.Shell = GetNextArg(tokens, ref i, "--shell");
break;
case "--working-directory":
cmd.WorkingDirectory = GetNextArg(tokens, ref i, "--working-directory");
break;
case "--if":
cmd.Condition = GetNextArg(tokens, ref i, "--if");
break;
case "--env":
ParseEnvArg(tokens, ref i, cmd.Env);
break;
case "--continue-on-error":
cmd.ContinueOnError = true;
break;
case "--timeout":
cmd.Timeout = GetNextArgInt(tokens, ref i, "--timeout");
break;
case "--at":
cmd.Position = StepPosition.At(GetNextArgInt(tokens, ref i, "--at"));
break;
case "--after":
cmd.Position = StepPosition.After(GetNextArgInt(tokens, ref i, "--after"));
break;
case "--before":
cmd.Position = StepPosition.Before(GetNextArgInt(tokens, ref i, "--before"));
break;
case "--first":
cmd.Position = StepPosition.First();
break;
case "--last":
cmd.Position = StepPosition.Last();
break;
default:
throw new StepCommandException(StepCommandErrors.InvalidOption,
$"Unknown option: {tokens[i]}");
}
}
if (cmd.Env.Count == 0)
cmd.Env = null;
return cmd;
}
private AddUsesCommand ParseReplAddUsesCommand(List<string> tokens)
{
// !step add uses "action@ref" [options]
if (tokens.Count < 4)
{
throw new StepCommandException(StepCommandErrors.ParseError,
"Usage: !step add uses <action@ref> [--name \"...\"] [--with key=value] [--at|--after|--before <n>]");
}
var cmd = new AddUsesCommand
{
Action = tokens[3],
With = new Dictionary<string, string>(),
Env = new Dictionary<string, string>()
};
// Parse options
for (int i = 4; i < tokens.Count; i++)
{
var opt = tokens[i].ToLower();
switch (opt)
{
case "--name":
cmd.Name = GetNextArg(tokens, ref i, "--name");
break;
case "--with":
ParseKeyValueArg(tokens, ref i, cmd.With);
break;
case "--if":
cmd.Condition = GetNextArg(tokens, ref i, "--if");
break;
case "--env":
ParseEnvArg(tokens, ref i, cmd.Env);
break;
case "--continue-on-error":
cmd.ContinueOnError = true;
break;
case "--timeout":
cmd.Timeout = GetNextArgInt(tokens, ref i, "--timeout");
break;
case "--at":
cmd.Position = StepPosition.At(GetNextArgInt(tokens, ref i, "--at"));
break;
case "--after":
cmd.Position = StepPosition.After(GetNextArgInt(tokens, ref i, "--after"));
break;
case "--before":
cmd.Position = StepPosition.Before(GetNextArgInt(tokens, ref i, "--before"));
break;
case "--first":
cmd.Position = StepPosition.First();
break;
case "--last":
cmd.Position = StepPosition.Last();
break;
default:
throw new StepCommandException(StepCommandErrors.InvalidOption,
$"Unknown option: {tokens[i]}");
}
}
if (cmd.With.Count == 0)
cmd.With = null;
if (cmd.Env.Count == 0)
cmd.Env = null;
return cmd;
}
private EditCommand ParseReplEditCommand(List<string> tokens)
{
// !step edit <index> [modifications]
if (tokens.Count < 3)
{
throw new StepCommandException(StepCommandErrors.ParseError,
"Usage: !step edit <index> [--name \"...\"] [--script \"...\"] [--if \"...\"]");
}
if (!int.TryParse(tokens[2], out var index))
{
throw new StepCommandException(StepCommandErrors.ParseError,
$"Invalid index: {tokens[2]}. Must be a number.");
}
var cmd = new EditCommand
{
Index = index,
With = new Dictionary<string, string>(),
Env = new Dictionary<string, string>(),
RemoveWith = new List<string>(),
RemoveEnv = new List<string>()
};
// Parse options
for (int i = 3; i < tokens.Count; i++)
{
var opt = tokens[i].ToLower();
switch (opt)
{
case "--name":
cmd.Name = GetNextArg(tokens, ref i, "--name");
break;
case "--script":
cmd.Script = GetNextArg(tokens, ref i, "--script");
break;
case "--action":
cmd.Action = GetNextArg(tokens, ref i, "--action");
break;
case "--shell":
cmd.Shell = GetNextArg(tokens, ref i, "--shell");
break;
case "--working-directory":
cmd.WorkingDirectory = GetNextArg(tokens, ref i, "--working-directory");
break;
case "--if":
cmd.Condition = GetNextArg(tokens, ref i, "--if");
break;
case "--with":
ParseKeyValueArg(tokens, ref i, cmd.With);
break;
case "--env":
ParseEnvArg(tokens, ref i, cmd.Env);
break;
case "--remove-with":
cmd.RemoveWith.Add(GetNextArg(tokens, ref i, "--remove-with"));
break;
case "--remove-env":
cmd.RemoveEnv.Add(GetNextArg(tokens, ref i, "--remove-env"));
break;
case "--continue-on-error":
cmd.ContinueOnError = true;
break;
case "--no-continue-on-error":
cmd.ContinueOnError = false;
break;
case "--timeout":
cmd.Timeout = GetNextArgInt(tokens, ref i, "--timeout");
break;
default:
throw new StepCommandException(StepCommandErrors.InvalidOption,
$"Unknown option: {tokens[i]}");
}
}
// Clean up empty collections
if (cmd.With.Count == 0)
cmd.With = null;
if (cmd.Env.Count == 0)
cmd.Env = null;
if (cmd.RemoveWith.Count == 0)
cmd.RemoveWith = null;
if (cmd.RemoveEnv.Count == 0)
cmd.RemoveEnv = null;
return cmd;
}
private RemoveCommand ParseReplRemoveCommand(List<string> tokens)
{
// !step remove <index>
if (tokens.Count < 3)
{
throw new StepCommandException(StepCommandErrors.ParseError,
"Usage: !step remove <index>");
}
if (!int.TryParse(tokens[2], out var index))
{
throw new StepCommandException(StepCommandErrors.ParseError,
$"Invalid index: {tokens[2]}. Must be a number.");
}
return new RemoveCommand { Index = index };
}
private MoveCommand ParseReplMoveCommand(List<string> tokens)
{
// !step move <from> --to|--after|--before <index>|--first|--last
if (tokens.Count < 3)
{
throw new StepCommandException(StepCommandErrors.ParseError,
"Usage: !step move <from> --to|--after|--before <index>|--first|--last");
}
if (!int.TryParse(tokens[2], out var fromIndex))
{
throw new StepCommandException(StepCommandErrors.ParseError,
$"Invalid from index: {tokens[2]}. Must be a number.");
}
var cmd = new MoveCommand { FromIndex = fromIndex };
// Parse position
for (int i = 3; i < tokens.Count; i++)
{
var opt = tokens[i].ToLower();
switch (opt)
{
case "--to":
case "--at":
cmd.Position = StepPosition.At(GetNextArgInt(tokens, ref i, opt));
break;
case "--after":
cmd.Position = StepPosition.After(GetNextArgInt(tokens, ref i, "--after"));
break;
case "--before":
cmd.Position = StepPosition.Before(GetNextArgInt(tokens, ref i, "--before"));
break;
case "--first":
cmd.Position = StepPosition.First();
break;
case "--last":
cmd.Position = StepPosition.Last();
break;
default:
throw new StepCommandException(StepCommandErrors.InvalidOption,
$"Unknown option: {tokens[i]}");
}
}
if (cmd.Position == null)
{
throw new StepCommandException(StepCommandErrors.ParseError,
"Move command requires a position (--to, --after, --before, --first, or --last)");
}
return cmd;
}
private ExportCommand ParseReplExportCommand(List<string> tokens)
{
var cmd = new ExportCommand();
for (int i = 2; i < tokens.Count; i++)
{
var opt = tokens[i].ToLower();
switch (opt)
{
case "--changes-only":
cmd.ChangesOnly = true;
break;
case "--with-comments":
cmd.WithComments = true;
break;
default:
throw new StepCommandException(StepCommandErrors.InvalidOption,
$"Unknown option: {tokens[i]}");
}
}
return cmd;
}
#region Argument Helpers
private string GetNextArg(List<string> tokens, ref int index, string optName)
{
if (index + 1 >= tokens.Count)
{
throw new StepCommandException(StepCommandErrors.ParseError,
$"Option {optName} requires a value");
}
return tokens[++index];
}
private int GetNextArgInt(List<string> tokens, ref int index, string optName)
{
var value = GetNextArg(tokens, ref index, optName);
if (!int.TryParse(value, out var result))
{
throw new StepCommandException(StepCommandErrors.ParseError,
$"Option {optName} requires an integer value, got: {value}");
}
return result;
}
private void ParseEnvArg(List<string> tokens, ref int index, Dictionary<string, string> env)
{
ParseKeyValueArg(tokens, ref index, env);
}
private void ParseKeyValueArg(List<string> tokens, ref int index, Dictionary<string, string> dict)
{
var value = GetNextArg(tokens, ref index, "key=value");
var eqIndex = value.IndexOf('=');
if (eqIndex <= 0)
{
throw new StepCommandException(StepCommandErrors.ParseError,
$"Expected key=value format, got: {value}");
}
var key = value.Substring(0, eqIndex);
var val = value.Substring(eqIndex + 1);
dict[key] = val;
}
#endregion
#endregion
}
}