diff --git a/README.md b/README.md index 11e3280..bc44db1 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Alternatively, add the following line to your `.csproj` file: | **InputDirectory**
*(Required)* | The directory containing the files that need to be aggregated. | | | | **InputType** | Specifies the format of the input files. Refer to the [File Types](#file-types) table below for the corresponding file extensions that will be searched for. | `Json`, `Arm`, `Yaml` | `Yaml` | | **AddSourceProperty** | Adds a `source` property to each object in the output, specifying the filename from which the object originated. | `true`, `false` | `false` | -| **AdditionalProperties** | A set of custom top-level properties to include in the final output. Use `ItemGroup` syntax to define key-value pairs. | | | +| **AdditionalProperties** | A set of custom top-level properties to include in the final output. Use `ItemGroup` syntax to define key-value pairs. See [below](#additional-properties) for usage details. | | | | **IsQuietMode** | When true, only warning and error logs are generated by the task, suppressing standard informational output. | `true`, `false` | `false` | ## File Types @@ -60,18 +60,12 @@ In your `.csproj` file, use the task to aggregate YAML files and output them in - - - - - + OutputType="Json" /> @@ -81,7 +75,6 @@ In this example: - The `Configs` directory contains the YAML files to be aggregated. - The output will be generated as `out/output.json`. - The `AddSourceProperty` flag adds the source file name to each configuration entry. -- The `AdditionalProperties` are injected into the top-level of the output as custom metadata. ### ARM Template Parameters Output Example @@ -91,16 +84,10 @@ You can also generate Azure ARM template parameters. Here's how to modify the co - - - - - + OutputType="Arm" /> @@ -110,15 +97,40 @@ You can also generate Azure ARM template parameters. Here's how to modify the co You can also output the aggregated configuration back into YAML format: +```xml + + + + + + + +``` + +### Additional Properties + +At build time, you can inject additional properties into the top-level of your output configuration as key-value pairs. Conditionals and variables are supported. + +In this example, two additional properties (`ResourceGroup` and `Environment`) are defined and will be included in the YAML output's top-level structure. This allows for dynamic property injection at build time. + ```xml - - + + + TestRG + + + Production + + ``` +#### Explanation: +- **Additional Properties:** The `AdditionalProperty` items store key-value pairs (`ResourceGroup=TestRG` and `Environment=Production`). The key is set in the `Include` attribute, and the value is defined in a nested `` element. +- **ItemGroup:** Groups the additional properties, which will later be referenced in the task as `@(AdditionalProperty)`. +- **AggregateConfig Task:** This task collects the configurations from the `Configs` directory and aggregates them into a YAML output file. The `AdditionalProperties` item group is passed to the task, ensuring that the properties are injected into the top-level of the output. + ### Embedding Output Files as Resources You can embed the output files (such as the generated JSON) as resources in the assembly. This allows them to be accessed from within your code as embedded resources. @@ -137,16 +154,10 @@ You can embed the output files (such as the generated JSON) as resources in the - - - - - + OutputType="Json" /> diff --git a/src/Task/AggregateConfig.cs b/src/Task/AggregateConfig.cs index 555a307..0e9f9e1 100644 --- a/src/Task/AggregateConfig.cs +++ b/src/Task/AggregateConfig.cs @@ -1,9 +1,9 @@ -using AggregateConfigBuildTask.FileHandlers; -using Microsoft.Build.Framework; -using System; +using System; using System.IO; using System.Reflection; using System.Runtime.CompilerServices; +using AggregateConfigBuildTask.FileHandlers; +using Microsoft.Build.Framework; using Task = Microsoft.Build.Utilities.Task; [assembly: InternalsVisibleTo("AggregateConfig.Tests.UnitTests")] @@ -49,7 +49,7 @@ public class AggregateConfig : Task /// /// An array of additional properties that can be included in the output. These are user-specified key-value pairs. /// - public string[] AdditionalProperties { get; set; } + public ITaskItem[] AdditionalProperties { get; set; } /// /// Gets or sets whether quiet mode is enabled. When enabled, the logger will suppress non-critical messages. @@ -91,6 +91,7 @@ public override bool Execute() { EmitHeader(); + InputDirectory = Path.GetFullPath(InputDirectory); OutputFile = Path.GetFullPath(OutputFile); if (!Enum.TryParse(OutputType, true, out FileType outputType) || diff --git a/src/Task/JsonHelper.cs b/src/Task/JsonHelper.cs index 06725c6..c9d0ce8 100644 --- a/src/Task/JsonHelper.cs +++ b/src/Task/JsonHelper.cs @@ -3,6 +3,7 @@ using System.IO; using System.Text.Json; using System.Threading.Tasks; +using Microsoft.Build.Framework; namespace AggregateConfigBuildTask { @@ -63,12 +64,13 @@ public static object ConvertKeysToString(object data) } /// - /// Parses an array of key-value pairs provided as strings in the format "key=value". + /// Parses an array of key-value pairs from an array. + /// Prioritizes the 'Value' metadata when provided. Falls back to legacy format "key=value" if no 'Value' is present. /// Supports escaping of the '=' sign using '\='. /// - /// An array of key-value pairs in the form "key=value". + /// An array of key-value pairs in the form of ITaskItem[] or legacy format strings. /// A dictionary containing the parsed key-value pairs. - public static Dictionary ParseAdditionalProperties(string[] properties) + public static Dictionary ParseAdditionalProperties(ITaskItem[] properties) { var additionalPropertiesDict = new Dictionary(); const string unicodeEscape = "\u001F"; @@ -78,8 +80,19 @@ public static Dictionary ParseAdditionalProperties(string[] prop { foreach (var property in properties) { - var sanitizedProperty = property.Replace(@"\=", unicodeEscape); + // Check if the new 'Value' metadata is present + string key = property.ItemSpec; + string value = property.GetMetadata("Value"); + if (!string.IsNullOrEmpty(value)) + { + // Use the new method if the 'Value' metadata is provided + additionalPropertiesDict[key] = value; + continue; + } + + // Fallback to legacy parsing if no 'Value' metadata is provided and key contains '=' + var sanitizedProperty = key.Replace(@"\=", unicodeEscape); var keyValue = sanitizedProperty.Split(split, 2); if (keyValue.Length == 2) @@ -88,6 +101,7 @@ public static Dictionary ParseAdditionalProperties(string[] prop } } } + return additionalPropertiesDict; } diff --git a/src/UnitTests/PropertyExtensions.cs b/src/UnitTests/PropertyExtensions.cs new file mode 100644 index 0000000..69cc39d --- /dev/null +++ b/src/UnitTests/PropertyExtensions.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace AggregateConfigBuildTask.Tests.Unit +{ + internal static class PropertyExtensions + { + /// + /// Creates an array of TaskItems from a dictionary of key-value pairs. + /// Supports both legacy format (key=value in ItemSpec) and new format (key in ItemSpec, value in metadata). + /// + /// Dictionary of key-value pairs to be converted to TaskItems. + /// If true, uses the legacy format for all items; otherwise, uses new format. + /// An array of TaskItems. + public static ITaskItem[] CreateTaskItems(this IDictionary properties, bool legacyAdditionalProperties) + { + return properties.Select((q) => { + if (legacyAdditionalProperties) + { + // Legacy format: "Key=Value" in ItemSpec + return new TaskItem($"{q.Key}={q.Value}"); + } + + // New format: Key in ItemSpec, Value as metadata + var taskItem = new TaskItem(q.Key); + taskItem.SetMetadata("Value", q.Value); + return taskItem; + }).ToArray(); + } + } +} diff --git a/src/UnitTests/TaskTestBase.cs b/src/UnitTests/TaskTestBase.cs index 2179eac..6f42c64 100644 --- a/src/UnitTests/TaskTestBase.cs +++ b/src/UnitTests/TaskTestBase.cs @@ -1,15 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; using Microsoft.Build.Framework; using Moq; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; namespace AggregateConfigBuildTask.Tests.Unit { - public class TaskTestBase + public abstract class TaskTestBase { private string testPath; private readonly StringComparison comparison = StringComparison.OrdinalIgnoreCase; @@ -17,6 +17,8 @@ public class TaskTestBase private Mock mockLogger; internal IFileSystem virtualFileSystem; + public TestContext TestContext { get; set; } + public void TestInitialize(bool isWindowsMode, string testPath) { this.testPath = testPath; @@ -25,6 +27,17 @@ public void TestInitialize(bool isWindowsMode, string testPath) this.virtualFileSystem.CreateDirectory(testPath); } + [TestCleanup] + public void Cleanup() + { + foreach (var invocation in mockLogger.Invocations) + { + var methodName = invocation.Method.Name; + var arguments = string.Join(", ", invocation.Arguments); + TestContext.WriteLine($"Logger call: {methodName}({arguments})"); + } + } + [TestMethod] [Description("Test that YAML files are merged into correct JSON output.")] public void ShouldGenerateJsonOutput() @@ -176,7 +189,9 @@ public void ShouldAddSourcePropertyMultipleFiles() [TestMethod] [Description("Test that additional properties are correctly added to the top level in JSON output.")] - public void ShouldIncludeAdditionalPropertiesInJson() + [DataRow(true, DisplayName = "Legacy properties")] + [DataRow(false, DisplayName = "Modern properties")] + public void ShouldIncludeAdditionalPropertiesInJson(bool useLegacyAdditionalProperties) { // Arrange: Prepare sample YAML data. virtualFileSystem.WriteAllText($"{testPath}\\file1.yml", @" @@ -194,7 +209,7 @@ public void ShouldIncludeAdditionalPropertiesInJson() { { "Group", "TestRG" }, { "Environment\\=Key", "Prod\\=West" } - }.Select(q => $"{q.Key}={q.Value}").ToArray(), + }.CreateTaskItems(useLegacyAdditionalProperties), BuildEngine = Mock.Of() }; @@ -206,12 +221,22 @@ public void ShouldIncludeAdditionalPropertiesInJson() string output = virtualFileSystem.ReadAllText($"{testPath}\\output.json"); var json = JsonConvert.DeserializeObject>(output); Assert.AreEqual("TestRG", json["Group"]); - Assert.AreEqual("Prod=West", json["Environment=Key"]); + + if (useLegacyAdditionalProperties) + { + Assert.AreEqual("Prod=West", json["Environment=Key"]); + } + else + { + Assert.AreEqual("Prod\\=West", json["Environment\\=Key"]); + } } [TestMethod] [Description("Test that additional properties are correctly added to the ARM parameters output.")] - public void ShouldIncludeAdditionalPropertiesInArmParameters() + [DataRow(true, DisplayName = "Legacy properties")] + [DataRow(false, DisplayName = "Modern properties")] + public void ShouldIncludeAdditionalPropertiesInArmParameters(bool useLegacyAdditionalProperties) { // Arrange: Prepare sample YAML data. virtualFileSystem.WriteAllText($"{testPath}\\file1.yml", @" @@ -229,7 +254,7 @@ public void ShouldIncludeAdditionalPropertiesInArmParameters() { { "Group", "TestRG" }, { "Environment", "Prod" } - }.Select(q => $"{q.Key}={q.Value}").ToArray(), + }.CreateTaskItems(useLegacyAdditionalProperties), BuildEngine = Mock.Of() }; @@ -327,7 +352,9 @@ public void ShouldCorrectlyParseBooleanValues() [TestMethod] [Description("Test that additional properties are correctly added to the ARM parameters output from JSON input.")] - public void ShouldIncludeAdditionalPropertiesInJsonInput() + [DataRow(true, DisplayName = "Legacy properties")] + [DataRow(false, DisplayName = "Modern properties")] + public void ShouldIncludeAdditionalPropertiesInJsonInput(bool useLegacyAdditionalProperties) { // Arrange: Prepare sample JSON data. virtualFileSystem.WriteAllText($"{testPath}\\file1.json", """ @@ -353,7 +380,7 @@ public void ShouldIncludeAdditionalPropertiesInJsonInput() { { "Group", "TestRG" }, { "Environment", "Prod" } - }.Select(q => $"{q.Key}={q.Value}").ToArray(), + }.CreateTaskItems(useLegacyAdditionalProperties), BuildEngine = Mock.Of() }; @@ -375,7 +402,9 @@ public void ShouldIncludeAdditionalPropertiesInJsonInput() [TestMethod] [Description("Test that ARM parameters are correctly processed and additional properties are included in the output.")] - public void ShouldIncludeAdditionalPropertiesInArmParameterFile() + [DataRow(true, DisplayName = "Legacy properties")] + [DataRow(false, DisplayName = "Modern properties")] + public void ShouldIncludeAdditionalPropertiesInArmParameterFile(bool useLegacyAdditionalProperties) { // Arrange: Prepare ARM template parameter file data in 'file1.parameters.json'. virtualFileSystem.WriteAllText($"{testPath}\\file1.parameters.json", """ @@ -406,7 +435,7 @@ public void ShouldIncludeAdditionalPropertiesInArmParameterFile() { { "Group", "TestRG" }, { "Environment", "'Prod'" } - }.Select(q => $"{q.Key}={q.Value}").ToArray(), + }.CreateTaskItems(useLegacyAdditionalProperties), BuildEngine = Mock.Of() }; diff --git a/test/IntegrationTests/IntegrationTests.csproj b/test/IntegrationTests/IntegrationTests.csproj index ba711d9..c0387b9 100644 --- a/test/IntegrationTests/IntegrationTests.csproj +++ b/test/IntegrationTests/IntegrationTests.csproj @@ -1,4 +1,4 @@ - + AggregateConfig.Tests.Integration @@ -45,16 +45,27 @@ - - - + + + + + + + MyProject + + + 1.0.0 + + + John Doe + + AdditionalProperties="@(AdditionalConfig)"> @@ -76,60 +87,56 @@ - - - - - + OutputType="Json" /> - - - - - + OutputType="Arm" /> - - - - - + OutputType="Yaml" /> + + + + + + TestRG + + + Production + + + + - - - - - + + + + OutputFile="$(MSBuildProjectDirectory)\out\sample5_output.json" + OutputType="Json" /> - +