diff --git a/README.md b/README.md index b3c6042..d2301d2 100644 --- a/README.md +++ b/README.md @@ -52,10 +52,12 @@ The SpdxTool can be driven using workflow yaml files of the following format: ```yaml steps: - command: - + inputs: + - command: - + inputs: + ``` ## YAML Commands @@ -67,24 +69,30 @@ steps: # Run a separate workflow file - command: run-workflow - file: other-workflow-file.yaml + inputs: + file: other-workflow-file.yaml + parameters: + # Create a summary markdown from the specified SPDX document - command: to-markdown - spdx: input.spdx.json - markdown: output.md + inputs: + spdx: input.spdx.json + markdown: output.md # Rename the SPDX-ID of an element in an SPDX document - command: rename-id - spdx: - old: - new: + inputs: + spdx: + old: + new: # Copy a package from one SPDX document to another SPDX document - command: copy-package - from: - to: - package: - relationship: - element: + inputs: + from: + to: + package: + relationship: + element: ``` diff --git a/src/DemaConsulting.SpdxTool/Commands/Command.cs b/src/DemaConsulting.SpdxTool/Commands/Command.cs index 6f28e39..23322c4 100644 --- a/src/DemaConsulting.SpdxTool/Commands/Command.cs +++ b/src/DemaConsulting.SpdxTool/Commands/Command.cs @@ -17,5 +17,88 @@ public abstract class Command /// Run the command /// /// Command step - public abstract void Run(YamlMappingNode step); -} \ No newline at end of file + /// Workflow variables + public abstract void Run(YamlMappingNode step, Dictionary variables); + + /// + /// Expand variables in text + /// + /// Text to expand + /// Variables + /// Expanded text + /// on error + public static string Expand(string text, Dictionary variables) + { + while (true) + { + // Find the last macro to expand + var start = text.LastIndexOf("${{", StringComparison.Ordinal); + if (start < 0) + return text; + + // Find the end of the macro + var end = text.IndexOf("}}", start, StringComparison.Ordinal); + if (end < 0) + throw new InvalidOperationException("Unmatched '${{' in variable expansion"); + + // Get the variable name + var name = text[(start + 3)..end].Trim(); + + // Replace the variable + if (!variables.TryGetValue(name, out var value)) + throw new InvalidOperationException($"Undefined variable {name}"); + + // Apply the replacement + text = text[..start] + value + text[(end + 2)..]; + } + } + + /// + /// Get a map from a map + /// + /// Parent map node + /// Entry name + /// Child map node or null + public static YamlMappingNode? GetMapMap(YamlMappingNode? map, string name) + { + // Handle null map + if (map == null) + return null; + + // Get the entry + return map.Children.TryGetValue(name, out var value) ? value as YamlMappingNode : null; + } + + /// + /// Get a sequence from a map + /// + /// Parent map node + /// Entry name + /// Child sequence node or null + public static YamlSequenceNode? GetMapSequence(YamlMappingNode? map, string name) + { + // Handle null map + if (map == null) + return null; + + // Get the entry + return map.Children.TryGetValue(name, out var value) ? value as YamlSequenceNode : null; + } + + /// + /// Get a map value from a map + /// + /// Map node + /// Map key + /// Variables for expansion + /// Map value or null + public static string? GetMapString(YamlMappingNode? map, string key, Dictionary variables) + { + // Handle null map + if (map == null) + return null; + + // Get the parameter + return map.Children.TryGetValue(key, out var value) ? Expand(value.ToString(), variables) : null; + } +} diff --git a/src/DemaConsulting.SpdxTool/Commands/CopyPackageCommand.cs b/src/DemaConsulting.SpdxTool/Commands/CopyPackageCommand.cs index e3adfec..7417d1a 100644 --- a/src/DemaConsulting.SpdxTool/Commands/CopyPackageCommand.cs +++ b/src/DemaConsulting.SpdxTool/Commands/CopyPackageCommand.cs @@ -31,11 +31,12 @@ public class CopyPackageCommand : Command "", "From a YAML file this can be used as:", " - command: copy-package", - " from: ", - " to: ", - " package: ", - " relationship: ", - " element: ", + " inputs:", + " from: ", + " to: ", + " package: ", + " relationship: ", + " element: ", "", "The argument is the name of a package in to copy.", "The argument describes the relationship to .", @@ -65,31 +66,33 @@ public override void Run(string[] args) } /// - public override void Run(YamlMappingNode step) + public override void Run(YamlMappingNode step, Dictionary variables) { - // Get the from-filename - if (!step.Children.TryGetValue("from", out var fromFile)) - throw new YamlException(step.Start, step.End, "'copy-package' command missing 'from' parameter"); + // Get the step inputs + var inputs = GetMapMap(step, "inputs"); - // Get the to-filename - if (!step.Children.TryGetValue("to", out var toFile)) - throw new YamlException(step.Start, step.End, "'copy-package' command missing 'to' parameter"); + // Get the 'from' input + var fromFile = GetMapString(inputs, "from", variables) ?? + throw new YamlException(step.Start, step.End, "'copy-package' missing 'from' input"); - // Get the package name - if (!step.Children.TryGetValue("package", out var package)) - throw new YamlException(step.Start, step.End, "'copy-package' command missing 'package' parameter"); + // Get the 'to' input + var toFile = GetMapString(inputs, "to", variables) ?? + throw new YamlException(step.Start, step.End, "'copy-package' missing 'to' input"); - // Get the relationship type - if (!step.Children.TryGetValue("relationship", out var relationship)) - throw new YamlException(step.Start, step.End, "'copy-package' command missing 'relationship' parameter"); + // Get the 'package' input + var package = GetMapString(inputs, "package", variables) ?? + throw new YamlException(step.Start, step.End, "'copy-package' missing 'package' input"); - // Get the element name - if (!step.Children.TryGetValue("element", out var element)) - throw new YamlException(step.Start, step.End, "'copy-package' command missing 'element' parameter"); + // Get the 'relationship' input + var relationship = GetMapString(inputs, "relationship", variables) ?? + throw new YamlException(step.Start, step.End, "'copy-package' missing 'relationship' input"); + + // Get the 'element' input + var element = GetMapString(inputs, "element", variables) ?? + throw new YamlException(step.Start, step.End, "'copy-package' missing 'element' input"); // Copy the package - CopyPackage(fromFile.ToString(), toFile.ToString(), package.ToString(), relationship.ToString(), - element.ToString()); + CopyPackage(fromFile, toFile, package, relationship, element); } /// diff --git a/src/DemaConsulting.SpdxTool/Commands/HelpCommand.cs b/src/DemaConsulting.SpdxTool/Commands/HelpCommand.cs index 7ee43d4..d6bc329 100644 --- a/src/DemaConsulting.SpdxTool/Commands/HelpCommand.cs +++ b/src/DemaConsulting.SpdxTool/Commands/HelpCommand.cs @@ -29,7 +29,8 @@ public class HelpCommand : Command "", "From a YAML file this can be used as:", " - command: help", - " about: " + " inputs:", + " about: " }, Instance); @@ -52,14 +53,17 @@ public override void Run(string[] args) } /// - public override void Run(YamlMappingNode step) + public override void Run(YamlMappingNode step, Dictionary variables) { - // Get the about command - if (!step.Children.TryGetValue("about", out var about)) - throw new YamlException(step.Start, step.End, "'help' command missing 'about' parameter"); + // Get the step inputs + var inputs = GetMapMap(step, "inputs"); + + // Get the 'about' input + var about = GetMapString(inputs, "about", variables) ?? + throw new YamlException(step.Start, step.End, "'help' command missing 'about' input"); // Generate the markdown - ShowUsage(about.ToString()); + ShowUsage(about); } /// diff --git a/src/DemaConsulting.SpdxTool/Commands/RenameIdCommand.cs b/src/DemaConsulting.SpdxTool/Commands/RenameIdCommand.cs index 7437f2c..0a46e7e 100644 --- a/src/DemaConsulting.SpdxTool/Commands/RenameIdCommand.cs +++ b/src/DemaConsulting.SpdxTool/Commands/RenameIdCommand.cs @@ -30,9 +30,10 @@ public class RenameIdCommand : Command "", "From a YAML file this can be used as:", " - command: rename-id", - " spdx: ", - " old: ", - " new: " + " inputs:", + " spdx: ", + " old: ", + " new: " }, Instance); @@ -55,22 +56,25 @@ public override void Run(string[] args) } /// - public override void Run(YamlMappingNode step) + public override void Run(YamlMappingNode step, Dictionary variables) { - // Get the spdx filename - if (!step.Children.TryGetValue("spdx", out var spdxFile)) - throw new YamlException(step.Start, step.End, "'rename-id' command missing 'spdx' parameter"); + // Get the step inputs + var inputs = GetMapMap(step, "inputs"); - // Get the old ID - if (!step.Children.TryGetValue("old", out var oldId)) - throw new YamlException(step.Start, step.End, "'rename-id' command missing 'old' parameter"); + // Get the 'spdx' input + var spdxFile = GetMapString(inputs, "spdx", variables) ?? + throw new YamlException(step.Start, step.End, "'rename-id' command missing 'spdx' input"); - // Get the new ID - if (!step.Children.TryGetValue("new", out var newId)) - throw new YamlException(step.Start, step.End, "'rename-id' command missing 'new' parameter"); + // Get the 'new' input + var newId = GetMapString(inputs, "new", variables) ?? + throw new YamlException(step.Start, step.End, "'rename-id' command missing 'new' input"); + + // Get the 'old' input + var oldId = GetMapString(inputs, "old", variables) ?? + throw new YamlException(step.Start, step.End, "'rename-id' command missing 'spdx' input"); // Rename the ID - RenameId(spdxFile.ToString(), oldId.ToString(), newId.ToString()); + RenameId(spdxFile, oldId, newId); } /// diff --git a/src/DemaConsulting.SpdxTool/Commands/RunWorkflowCommand.cs b/src/DemaConsulting.SpdxTool/Commands/RunWorkflowCommand.cs index 82774b1..cc06e13 100644 --- a/src/DemaConsulting.SpdxTool/Commands/RunWorkflowCommand.cs +++ b/src/DemaConsulting.SpdxTool/Commands/RunWorkflowCommand.cs @@ -25,11 +25,15 @@ public class RunWorkflowCommand : Command "This command runs the steps specified in the workflow.yaml file.", "", "From the command-line this can be used as:", - " spdx-tool run-workflow ", + " spdx-tool run-workflow [parameter=value] [parameter=value]...", "", "From a YAML file this can be used as:", " - command: run-workflow", - " file: " + " inputs:", + " file: ", + " parameters:", + " name: value", + " name: value" }, Instance); @@ -43,32 +47,64 @@ private RunWorkflowCommand() /// public override void Run(string[] args) { - // Report an error if the number of arguments is not 1 - if (args.Length != 1) + // Report an error if the number of arguments is less than 1 + if (args.Length < 1) throw new CommandUsageException("'run-workflow' command missing arguments"); + // Parse the parameters + var parameters = new Dictionary(); + foreach (var arg in args.Skip(1)) + { + // Verify the parameter is in the form key=value + var sep = arg.IndexOf('='); + if (sep < 0) + throw new CommandUsageException($"Invalid argument: {arg}"); + + // Add the parameter + var key = arg[..sep]; + var value = arg[(sep + 1)..]; + parameters[key] = value; + } + // Execute the workflow - Execute(args[0]); + Execute(args[0], parameters); } /// - public override void Run(YamlMappingNode step) + public override void Run(YamlMappingNode step, Dictionary variables) { - // Get the workflow filename - if (!step.Children.TryGetValue("file", out var file)) - throw new YamlException(step.Start, step.End, "'run-workflow' command missing 'file' parameter"); + // Get the step inputs + var inputs = GetMapMap(step, "input"); + + // Get the 'file' input + var file = GetMapString(inputs, "file", variables) ?? + throw new YamlException(step.Start, step.End, "'run-workflow' command missing 'file' input"); + + // Get the parameters + var parameters = new Dictionary(); + if (GetMapMap(inputs, "parameters") is { } parametersMap) + { + // Process all the parameters + foreach (var (keyNode, valueNode) in parametersMap.Children) + { + var key = keyNode.ToString(); + var value = valueNode.ToString(); + parameters[key] = Expand(value, variables); + } + } // Execute the workflow - Execute(file.ToString()); + Execute(file, parameters); } /// /// Execute the workflow /// /// Workflow file + /// Workflow parameters /// On usage error /// On workflow error - public static void Execute(string workflowFile) + public static void Execute(string workflowFile, Dictionary parameters) { // Verify the file exists if (!File.Exists(workflowFile)) @@ -85,10 +121,35 @@ public static void Execute(string workflowFile) throw new CommandErrorException( $"Workflow {workflowFile} missing root mapping node"); + // Process the parameters definitions into local variables + var variables = new Dictionary(); + if (GetMapMap(root, "parameters") is { } parametersMap) + { + // Process all the parameters + foreach (var (keyNode, valueNode) in parametersMap.Children) + { + var key = keyNode.ToString(); + var value = Expand(valueNode.ToString(), variables); + variables[key] = Expand(value, parameters); + } + } + + // Apply the provided parameters to our variables + foreach (var (key, value) in parameters) + { + if (!variables.ContainsKey(key)) + throw new CommandErrorException( + $"Workflow {workflowFile} parameter {key} not defined"); + + variables[key] = Expand(value, variables); + } + // Get the steps - var steps = root["steps"] as YamlSequenceNode ?? + var steps = GetMapSequence(root, "steps") ?? throw new CommandErrorException( $"Workflow {workflowFile} missing steps"); + + // Execute the steps foreach (var stepNode in steps) { // Get the step @@ -108,7 +169,7 @@ public static void Execute(string workflowFile) $"Unknown command: '{command}'"); // Run the command - entry.Instance.Run(step); + entry.Instance.Run(step, variables); } } catch (KeyNotFoundException ex) diff --git a/src/DemaConsulting.SpdxTool/Commands/ToMarkdownCommand.cs b/src/DemaConsulting.SpdxTool/Commands/ToMarkdownCommand.cs index db27073..fadeda7 100644 --- a/src/DemaConsulting.SpdxTool/Commands/ToMarkdownCommand.cs +++ b/src/DemaConsulting.SpdxTool/Commands/ToMarkdownCommand.cs @@ -31,8 +31,9 @@ public class ToMarkdownCommand : Command "", "From a YAML file this can be used as:", " - command: to-markdown", - " spdx: ", - " markdown: " + " inputs:", + " spdx: ", + " markdown: " }, Instance); @@ -55,18 +56,21 @@ public override void Run(string[] args) } /// - public override void Run(YamlMappingNode step) + public override void Run(YamlMappingNode step, Dictionary variables) { - // Get the SPDX filename - if (!step.Children.TryGetValue("spdx", out var spdxFile)) - throw new YamlException(step.Start, step.End, "'to-markdown' command missing 'spdx' parameter"); + // Get the step inputs + var inputs = GetMapMap(step, "inputs"); - // Get the Markdown filename - if (!step.Children.TryGetValue("markdown", out var markdownFile)) - throw new YamlException(step.Start, step.End, "'to-markdown' command missing 'markdown' parameter"); + // Get the 'spdx' input + var spdxFile = GetMapString(inputs, "spdx", variables) ?? + throw new YamlException(step.Start, step.End, "'to-markdown' command missing 'spdx' input"); + + // Get the 'markdown' input + var markdownFile = GetMapString(inputs, "markdown", variables) ?? + throw new YamlException(step.Start, step.End, "'to-markdown' command missing 'spdx' input"); // Generate the markdown - Generate(spdxFile.ToString(), markdownFile.ToString()); + Generate(spdxFile, markdownFile); } /// diff --git a/test/DemaConsulting.SpdxTool.Tests/TestCommand.cs b/test/DemaConsulting.SpdxTool.Tests/TestCommand.cs new file mode 100644 index 0000000..10aab25 --- /dev/null +++ b/test/DemaConsulting.SpdxTool.Tests/TestCommand.cs @@ -0,0 +1,75 @@ +using DemaConsulting.SpdxTool.Commands; +using YamlDotNet.RepresentationModel; + +namespace DemaConsulting.SpdxTool.Tests; + +[TestClass] +public class TestCommand +{ + [TestMethod] + public void CommandExpandMissing() + { + // Test expanding a missing variable + const string text = "Hello, ${{ name }}!"; + var variables = new Dictionary(); + Assert.ThrowsException(() => Command.Expand(text, variables)); + } + + [TestMethod] + public void CommandExpandNothing() + { + // Test expanding nothing + const string text = "Hello, world!"; + var variables = new Dictionary(); + var result = Command.Expand(text, variables); + Assert.AreEqual(text, result); + } + + [TestMethod] + public void CommandExpandBasic() + { + // Test expanding a basic variable + const string text = "Hello, ${{ name }}!"; + var variables = new Dictionary { { "name", "world" } }; + var result = Command.Expand(text, variables); + Assert.AreEqual("Hello, world!", result); + } + + [TestMethod] + public void CommandExpandDouble() + { + // Test expanding a nested variable + const string text = "Hello, ${{ name }}!"; + var variables = new Dictionary { { "name", "${{ target }}" }, { "target", "world" } }; + var result = Command.Expand(text, variables); + Assert.AreEqual("Hello, world!", result); + } + + [TestMethod] + public void CommandExpandNested() + { + // Test expanding a nested variable + const string text = "Hello, ${{ variable_${{ test }} }}!"; + var variables = new Dictionary { { "variable_foo", "world" }, { "test", "foo" } }; + var result = Command.Expand(text, variables); + Assert.AreEqual("Hello, world!", result); + } + + [TestMethod] + public void CommandGetMapStringMissing() + { + // Test getting a missing parameter + var map = new YamlMappingNode(); + var variables = new Dictionary(); + Assert.IsNull(Command.GetMapString(map, "parameter", variables)); + } + + [TestMethod] + public void CommandGetMapString() + { + // Test getting a parameter + var map = new YamlMappingNode { { "parameter", "Hello, ${{ name }}!" } }; + var variables = new Dictionary { { "name", "world" } }; + Assert.AreEqual("Hello, world!", Command.GetMapString(map, "parameter", variables)); + } +} \ No newline at end of file diff --git a/test/DemaConsulting.SpdxTool.Tests/TestRunWorkflow.cs b/test/DemaConsulting.SpdxTool.Tests/TestRunWorkflow.cs index f2ed20e..24312e2 100644 --- a/test/DemaConsulting.SpdxTool.Tests/TestRunWorkflow.cs +++ b/test/DemaConsulting.SpdxTool.Tests/TestRunWorkflow.cs @@ -54,7 +54,7 @@ public void RunWorkflowFileInvalid() // Verify error reported Assert.AreEqual(1, exitCode); - Assert.IsTrue(output.Contains("Error: Workflow invalid.yaml invalid")); + Assert.IsTrue(output.Contains("Error: Workflow invalid.yaml missing steps")); } finally { @@ -84,7 +84,7 @@ public void RunWorkflowMissingParameterInFile() // Verify error reported Assert.AreEqual(1, exitCode); - Assert.IsTrue(output.Contains("'help' command missing 'about' parameter")); + Assert.IsTrue(output.Contains("'help' command missing 'about' input")); } finally { @@ -98,7 +98,8 @@ public void RunWorkflow() { const string fileContents = "steps:\n" + "- command: help\n" + - " about: help\n"; + " inputs:\n" + + " about: help\n"; try { @@ -124,4 +125,77 @@ public void RunWorkflow() File.Delete("help.yaml"); } } + + [TestMethod] + public void RunWorkflowWithDefaultParameters() + { + const string fileContents = "parameters:\n" + + " about: help\n" + + "\n" + + "steps:\n" + + "- command: help\n" + + " inputs:\n" + + " about: ${{ about }}\n"; + + try + { + // Write the file + File.WriteAllText("help.yaml", fileContents); + + // Run the workflow + var exitCode = Runner.Run( + out var output, + "dotnet", + "DemaConsulting.SpdxTool.dll", + "run-workflow", + "help.yaml"); + + // Verify success + Assert.AreEqual(0, exitCode); + Assert.IsTrue( + output.Contains("This command displays extended help information about the specified command")); + } + finally + { + // Delete the file + File.Delete("help.yaml"); + } + } + + [TestMethod] + public void RunWorkflowWithSpecifiedParameters() + { + const string fileContents = "parameters:\n" + + " about: help\n" + + "\n" + + "steps:\n" + + "- command: help\n" + + " inputs:\n" + + " about: ${{ about }}\n"; + + try + { + // Write the file + File.WriteAllText("help.yaml", fileContents); + + // Run the workflow + var exitCode = Runner.Run( + out var output, + "dotnet", + "DemaConsulting.SpdxTool.dll", + "run-workflow", + "help.yaml", + "about=to-markdown"); + + // Verify success + Assert.AreEqual(0, exitCode); + Assert.IsTrue( + output.Contains("This command produces a Markdown summary of an SPDX document")); + } + finally + { + // Delete the file + File.Delete("help.yaml"); + } + } } \ No newline at end of file