diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cd339ca..e2a8eee 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -36,13 +36,16 @@ jobs: - name: Setup .NET Core SDK uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: - dotnet-version: '9.0.x' + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x - name: Set version id: get_version shell: pwsh run: | - $TAG = if ($env:GITHUB_REF -like "refs/tags/v*") { + $TAG = if ($env:GITHUB_REF -like "refs/tags/v*") { $env:GITHUB_REF -replace 'refs/tags/v', '' } else { "" @@ -88,7 +91,7 @@ jobs: matrix: os: [ubuntu-latest, windows-latest, macos-latest] runs-on: ${{ matrix.os }} - timeout-minutes: 10 + timeout-minutes: 15 steps: - name: Checkout repository @@ -99,7 +102,10 @@ jobs: - name: Setup .NET Core SDK uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: - dotnet-version: '9.0.x' + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x - name: Download NuGetPackage artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -120,4 +126,4 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: IntegrationTestResults-${{ matrix.os }} - path: test/IntegrationTests/out/ + path: test/IntegrationTests/out/ diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 196b579..c1dafe2 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -32,8 +32,7 @@ jobs: - name: Setup .NET Core SDK uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: - dotnet-version: '9.0.x' - dotnet-quality: 'preview' + dotnet-version: '10.0.x' - name: Setup Pages uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 @@ -124,4 +123,4 @@ jobs: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/.gitignore b/.gitignore index dda79a5..5f3c3bf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,16 @@ -**/bin/** -**/obj/** -**/out/ -**/.vs/** -*.nupkg -output*.json -_site/ -docs/ -src/.manifest +**/bin/ +**/obj/ +**/out/ +**/.vs/ +.vscode/ +*.nupkg +*.snupkg +output*.json +_site/ +docs/ +src/.manifest +test/packages/ +examples/generated/ +**/TestResults/ +*.user +*.suo diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md new file mode 100644 index 0000000..6337e40 --- /dev/null +++ b/MIGRATION_GUIDE.md @@ -0,0 +1,267 @@ +# Migration Guide: Item-Based Configuration + +This guide explains the new item-based configuration approach introduced to make `AggregateConfigBuildTask` work better with MSBuild's build process, similar to how `Azure.Bicep.MSBuild` operates. + +## What's New? + +### Item-Based Configuration (Recommended) + +The package now supports defining configuration files as MSBuild items (``), which allows: + +- **Automatic execution during build** - No need to create custom targets +- **Early build phase execution** - Runs by default before the `PrepareForBuild` target so generated files exist before resources are embedded, and the timing is customizable +- **Per-file configuration** - Different output settings per input file +- **Better MSBuild integration** - Proper incremental build support via `Inputs` and `Outputs` + +### Backward Compatibility + +**All existing projects continue to work without changes.** The legacy direct task invocation approach is fully supported. + +## Why the Change? + +Many MSBuild tasks need to run very early in the build process. This is especially true when generated files need to be embedded as resources or used as inputs to other build steps. The previous approach required users to: + +1. Create custom targets +2. Carefully order them with `BeforeTargets`/`AfterTargets` +3. Manually handle incremental builds + +The new item-based approach follows MSBuild best practices and allows the package to handle these concerns automatically. By default, it runs **before** the `Build` target, ensuring generated files are available for embedding as resources. + +## How to Migrate + +### Before (Legacy Approach) + +```xml + + + + + + + + + + + +``` + +### After (Item-Based Approach) + +```xml + + + + + + + + + + + +``` + +### Benefits of Migrating + +1. **Simpler project files** - No need to create targets +2. **Automatic incremental builds** - MSBuild tracks inputs/outputs +3. **Customizable execution timing** - Use properties to control when aggregation runs +4. **Multiple configurations easily** - Define multiple `` items + +## Running at Different Build Phases + +### Default Behavior (Before PrepareForBuild) + +By default, the target runs **before** the `PrepareForBuild` target, which is perfect for generating files that need to be embedded as resources: + +```xml + + + + + + +``` + +### Run Later (Before CoreCompile) + +If another target generates the input files after `PrepareForBuild`, move aggregation later so it still happens before compilation starts: + +```xml + + CoreCompile + + + + + +``` + +### Run After Build (Less Common) + +If you want to run after the build completes: + +```xml + + + Build + +``` + +## Advanced Scenarios + +### Multiple Outputs from Same Input + +```xml + + + + + + + + + + +``` + +### Preserve Directory Structure + +```xml + + + +``` + +This recursively finds all YAML files and preserves their directory structure in the output. + +### Custom Output Path for All Items + +```xml + + $(MSBuildProjectDirectory)\generated\ + + + + + + +``` + +## Available Properties + +| Property | Default | Description | +|----------|---------|-------------| +| `AggregateConfigCompileAfterTargets` | (empty) | Controls when the target runs (after which target) | +| `AggregateConfigCompileBeforeTargets` | `PrepareForBuild` | Controls when the target runs (before which target) | +| `AggregateConfigCompileDependsOn` | (empty) | Other targets that must run first | +| `AggregateConfigOutputPath` | `$(OutputPath)` | Default output directory for aggregated files | + +## Item Metadata + +Each `` item supports the following metadata: + +| Metadata | Required | Default | Description | +|----------|----------|---------|-------------| +| `OutputFile` | No | `$(AggregateConfigOutputPath)%(FileName).json` | Output file path | +| `OutputType` | No | `Json` | Output format (`Json`, `Yaml`, `Arm`, `Xml`) | +| `InputType` | No | `Yaml` | Input format (`Json`, `Yaml`, `Arm`, `Xml`) | +| `AddSourceProperty` | No | `false` | Add source filename to each object | + +\* Can be omitted if `AggregateConfigOutputPath` provides a suitable default + +## Compatibility Notes + +### Both Approaches Work Together + +You can use both the legacy and new approaches in the same project: + +```xml + + + + + + + + + +``` + +### No Breaking Changes + +All existing project files continue to work exactly as before. Migration is optional but recommended for new projects. + +## Troubleshooting + +### Aggregation Running at the Wrong Time + +**Problem**: Generated files aren't available when needed. + +**Solution**: Adjust `AggregateConfigCompileBeforeTargets` or `AggregateConfigCompileAfterTargets`: + +```xml + + + + CoreCompile + +``` + +### Multiple Executions + +**Problem**: Task runs multiple times. + +**Solution**: Ensure you're not mixing item-based and custom target approaches for the same files. The item-based approach handles batching automatically. + +### Output Files Not Created + +**Problem**: Expected output files don't exist. + +**Solution**: Check that: +1. `OutputFile` metadata or property is set correctly +2. `OutputType` metadata or property is set +3. Input files exist and match the `Include` pattern +4. Check build output for error messages + +## Getting Help + +- **Documentation**: https://docs.richardson.dev/AggregateConfigBuildTask +- **Issues**: https://github.com/richardsondev/AggregateConfigBuildTask/issues +- **Examples**: See `examples/ItemBasedExample.csproj` in the repository + +## Summary + +The item-based approach provides: +- ✅ Simpler project configuration +- ✅ Better MSBuild integration +- ✅ Automatic incremental builds +- ✅ Flexible execution timing +- ✅ Full backward compatibility + +Consider migrating to the new approach for new projects or when refactoring existing ones. diff --git a/README.md b/README.md index d9f7d1c..1ed5747 100644 --- a/README.md +++ b/README.md @@ -1,402 +1,513 @@ -# Aggregate Config Build Task - -[![NuGet Version](https://img.shields.io/nuget/v/AggregateConfigBuildTask)](https://www.nuget.org/packages/AggregateConfigBuildTask) [![GitHub Build Status](https://img.shields.io/github/actions/workflow/status/richardsondev/AggregateConfigBuildTask/build.yml?branch=main -)](https://github.com/richardsondev/AggregateConfigBuildTask/actions/workflows/build.yml?query=branch%3Amain) - -**AggregateConfigBuildTask** is a cross-platform MSBuild task that aggregates and transforms configuration files into more consumable formats like JSON, Azure ARM template parameters, YAML during the build process. - -## Features - -- Merge multiple configuration files into a single output format (JSON, Azure ARM parameters, or YAML). -- Support for injecting custom metadata (e.g., `ResourceGroup`, `Environment`) into the output. -- Optionally include the source file name in each configuration entry. -- Embed output files as resources in the assembly for easy inclusion in your project. - -## Links - -* Documentation: https://docs.richardson.dev/AggregateConfigBuildTask -* NuGet.org: https://www.nuget.org/packages/AggregateConfigBuildTask -* GitHub: https://github.com/richardsondev/AggregateConfigBuildTask - -## Installation - -To install the `AggregateConfigBuildTask` NuGet package, run the following command: - -```bash -dotnet add package AggregateConfigBuildTask -``` - -Alternatively, add the following line to your `.csproj` file: - -```xml - -``` - -`{latest}` can be found [here](https://www.nuget.org/packages/AggregateConfigBuildTask#versions-body-tab). - -## Parameters - -| Parameter | Description | Supported Values | Default | -|----------|----------|----------|----------| -| **OutputFile**
*(Required)* | The file path to write output to. Should include the extension. | | | -| **OutputType**
*(Required)* | Specifies the format of the output file. | `Json`, `Arm`, `Yaml` | | -| **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. 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 - -The `InputDirectory` will be scanned for files based on the specified `InputType`. The following table lists the file extensions that will be considered for each `InputType`: - -| **InputType** | **Extensions Scanned** | -|---------------|------------------------| -| `Json` | `.json` | -| `Arm` | `.json` | -| `Yaml` | `.yml`, `.yaml` | - -## Usage - -### Basic Example - -In your `.csproj` file, use the task to aggregate YAML files and output them in a specific format. Here’s an example of aggregating YAML files and generating JSON output: - -```xml - - - - - - - -``` - -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. - -### ARM Template Parameters Output Example - -You can also generate Azure ARM template parameters. Here's how to modify the configuration to output in the ARM parameter format: - -```xml - - - - - - - -``` - -### YAML Output Example - -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. - -```xml - - - - - - - - - - - - -``` - -In this example: -- The generated output file `output.json` is embedded in the resulting assembly as a resource. -- You can access this resource programmatically using the `System.Reflection` API. - -## Example YAML Input - -Assume you have the following YAML files in the `Configs` directory: - -```yaml -resources: - - id: "Resource1" - type: "Compute" - description: "Main compute resource" -``` - -```yaml -resources: - - id: "Resource2" - type: "Storage" - description: "Storage resource" -``` - -### Output JSON Example - -```json -{ - "resources": [ - { - "id": "Resource1", - "type": "Compute", - "description": "Main compute resource", - "source": "file1" - }, - { - "id": "Resource2", - "type": "Storage", - "description": "Storage resource", - "source": "file2" - } - ], - "ResourceGroup": "TestRG", - "Environment": "Production" -} -``` - -### ARM Parameter Output Example - -```json -{ - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "resources": { - "type": "array", - "value": [ - { - "id": "Resource1", - "type": "Compute", - "description": "Main compute resource", - "source": "file1" - }, - { - "id": "Resource2", - "type": "Storage", - "description": "Storage resource", - "source": "file2" - } - ] - }, - "ResourceGroup": { - "type": "string", - "value": "TestRG" - }, - "Environment": { - "type": "string", - "value": "Production" - } - } -} -``` - -## Accessing Embedded Resources in C# Assemblies - -Embedding resources such as configuration files into your assembly allows you to package all necessary data within a single executable or library. - -In the following example, we'll demonstrate how to read and deserialize an embedded resource at runtime. - -### Embedded resource - -Consider the following YML configuration files that you want to merge and embed into your assembly: - -**configs/global.yml** -```yml -enabled: true -``` - -**configs/prod.yml** -```yml -environment: Production -``` - -### Project file reference - -Your project should contain a reference similar to below: - -**application.csproj** -```xml - - - - - - - -``` - -### Reading the embedded resource - -To access and deserialize the embedded JSON resource, use the following method: - -**application.cs** -```csharp -using System; -using System.IO; -using System.Reflection; -using System.Text.Json; - -public static T LoadFromEmbeddedResource(string resourceName) -{ - var assembly = Assembly.GetExecutingAssembly(); - using var stream = assembly.GetManifestResourceStream(resourceName) - ?? throw new FileNotFoundException($"Resource '{resourceName}' not found in assembly."); - - return JsonSerializer.Deserialize(stream, new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }) ?? throw new InvalidOperationException("Failed to deserialize resource."); -} -``` - -### Defining the Configuration Class - -Create a class that matches the structure of your configuration: - -```csharp -public class AppConfig -{ - public bool Enabled { get; set; } - public string Environment { get; set; } -} -``` - -### Loading and Using the Configuration - -You can now load and use the configuration data as follows: - -```csharp -var applicationConfig = LoadFromEmbeddedResource("YourAssemblyName.out.output.json"); - -bool enabled = applicationConfig.Enabled; -Console.WriteLine($"Enabled: {enabled}"); // Outputs "True" - -string environment = applicationConfig.Environment; -Console.WriteLine($"Environment: {environment}"); // Outputs "Production" -``` - -**Note:** Replace `"YourAssemblyName.out.output.json"` with the actual resource name, which typically includes the assembly name, output folder, and the output file name. - -### Finding the Correct Resource Name - -If you're unsure about the exact resource name, you can retrieve all resource names in the assembly by adding the following code and inspecting the output: - -```csharp -string[] resourceNames = Assembly.GetExecutingAssembly().GetManifestResourceNames(); -foreach (var name in resourceNames) -{ - Console.WriteLine(name); -} -``` - -This will list all embedded resources, allowing you to confirm the correct name to use when loading the resource. - -## Development - -This project uses `dirs.proj` files with the Microsoft.Build.Traversal SDK for project organization instead of traditional solution files. This approach provides better performance and more flexibility for build scenarios. - -### Generating Solution Files - -If you need Visual Studio solution files for development, you can generate them using the [Microsoft.VisualStudio.SlnGen](https://github.com/microsoft/slngen) global tool: - -```bash -# Install the slngen global tool -dotnet tool install --global Microsoft.VisualStudio.SlnGen - -# Generate solution files from dirs.proj files -slngen src/dirs.proj --folders true -slngen test/dirs.proj --folders true - -# Or generate a solution for the entire repository -slngen dirs.proj --folders true -``` - -The generated solution files will include all projects referenced by the `dirs.proj` files and maintain the folder structure for easy navigation in Visual Studio. - -## License - -This project is licensed under the MIT License. See the [LICENSE](https://github.com/richardsondev/AggregateConfigBuildTask/blob/main/LICENSE) file for details. - -## Third-Party Libraries - -This project leverages the following third-party libraries that are bundled with the package: - -- **[YamlDotNet](https://github.com/aaubry/YamlDotNet)**\ - __Copyright (c) 2008, 2009, 2010, 2011, 2012, 2013, 2014 Antoine Aubry and contributors__\ - Used for YAML serialization and deserialization. YamlDotNet is distributed under the MIT License. For detailed information, refer to the [YamlDotNet License](https://github.com/aaubry/YamlDotNet/blob/master/LICENSE.txt). - -- **[YamlDotNet.System.Text.Json](https://github.com/IvanJosipovic/YamlDotNet.System.Text.Json)**\ - __Copyright (c) 2022 Ivan Josipovic__\ - Facilitates type handling for YAML serialization and deserialization, enhancing compatibility with System.Text.Json. This library is also distributed under the MIT License. For more details, see the [YamlDotNet.System.Text.Json License](https://github.com/IvanJosipovic/YamlDotNet.System.Text.Json/blob/main/LICENSE). - -## Contributing - -Contributions are welcome! Feel free to submit issues or pull requests on [GitHub](https://github.com/richardsondev/AggregateConfigBuildTask). +# Aggregate Config Build Task + +[![NuGet Version](https://img.shields.io/nuget/v/AggregateConfigBuildTask)](https://www.nuget.org/packages/AggregateConfigBuildTask) [![GitHub Build Status](https://img.shields.io/github/actions/workflow/status/richardsondev/AggregateConfigBuildTask/build.yml?branch=main +)](https://github.com/richardsondev/AggregateConfigBuildTask/actions/workflows/build.yml?query=branch%3Amain) + +**AggregateConfigBuildTask** is a cross-platform MSBuild task that aggregates and transforms configuration files into more consumable formats like JSON, Azure ARM template parameters, YAML during the build process. + +## Features + +- Merge multiple configuration files into a single output format (JSON, Azure ARM parameters, or YAML). +- Support for injecting custom metadata (e.g., `ResourceGroup`, `Environment`) into the output. +- Optionally include the source file name in each configuration entry. +- Embed output files as resources in the assembly for easy inclusion in your project. + +## Links + +* Documentation: https://docs.richardson.dev/AggregateConfigBuildTask +* NuGet.org: https://www.nuget.org/packages/AggregateConfigBuildTask +* GitHub: https://github.com/richardsondev/AggregateConfigBuildTask + +## Installation + +To install the `AggregateConfigBuildTask` NuGet package, run the following command: + +```bash +dotnet add package AggregateConfigBuildTask +``` + +Alternatively, add the following line to your `.csproj` file: + +```xml + +``` + +`{latest}` can be found [here](https://www.nuget.org/packages/AggregateConfigBuildTask#versions-body-tab). + +## Parameters + +| Parameter | Description | Supported Values | Default | +|----------|----------|----------|----------| +| **OutputFile**
*(Required)* | The file path to write output to. Should include the extension. | | | +| **OutputType**
*(Required)* | Specifies the format of the output file. | `Json`, `Arm`, `Yaml`, `Xml` | | +| **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`, `Xml` | `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. 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 + +The `InputDirectory` will be scanned for files based on the specified `InputType`. The following table lists the file extensions that will be considered for each `InputType`: + +| **InputType** | **Extensions Scanned** | +|---------------|------------------------| +| `Json` | `.json` | +| `Arm` | `.json` | +| `Yaml` | `.yml`, `.yaml` | +| `Xml` | `.xml` | + +XML has no native types, so it is mapped structurally: the root element's children become top-level properties, attributes become `@name` properties, an element with attributes and text gets its text under `#text`, and repeated child elements become an array. When writing XML, each JSON array becomes an element that contains one child per item, each named after the array property. Values that pass through XML come back as strings. + +## Usage + +The `AggregateConfigBuildTask` package supports two usage patterns: + +1. **Item-Based Approach (Recommended)**: Define `` items in your project file, and the package automatically processes them during build. +2. **Direct Task Invocation (Legacy)**: Manually invoke the `AggregateConfig` task in custom MSBuild targets. + +### Item-Based Approach (Recommended) + +When the `AggregateConfigBuildTask` package is included in a project file's `PackageReference` property, it automatically imports the `AggregateConfigCompile` target: + +```xml + + + +``` + +By default, the `AggregateConfigCompile` target runs before the `PrepareForBuild` target, processing all `@(AggregateConfigInput)` items and writing each merged result to the `OutputFile` given on the item (or to `$(AggregateConfigOutputPath)` when omitted). Running this early ensures generated files exist before they are embedded as resources or compiled. + +#### Basic Item-Based Example + +The following example shows how to aggregate configuration files by defining `AggregateConfigInput` items: + +```xml + + + +``` + +This will: +- Find all `.yml` files in the `configs` directory +- Merge them into a single JSON file +- Place the output at `$(OutputPath)\merged.json` + +#### Advanced Item-Based Example with Multiple Outputs + +You can define multiple `AggregateConfigInput` items with different output formats: + +```xml + + + + + + + + + + +``` + +#### Customizing Output Paths with Metadata + +You can use MSBuild metadata to create structured output paths: + +```xml + + + + +``` + +#### Customizing the Build Process + +You can customize when the `AggregateConfigCompile` target runs by setting the following properties: + +| Property Name | Default Value | Description | +|--------------|---------------|-------------| +| `AggregateConfigCompileAfterTargets` | None | Used as `AfterTargets` value for the `AggregateConfigCompile` target. Set this to run after a specific target. | +| `AggregateConfigCompileDependsOn` | None | Used as `DependsOnTargets` value for the `AggregateConfigCompile` target. Set this to targets that must run before aggregation. | +| `AggregateConfigCompileBeforeTargets` | `PrepareForBuild` | Used as `BeforeTargets` value for the `AggregateConfigCompile` target. Change this to control when aggregation occurs. | +| `AggregateConfigOutputPath` | `$(OutputPath)` | Set this property to override the default output path for aggregated files. `OutputFile` metadata on items takes precedence. | + +Example of customizing when the target runs, for instance when another target generates the input files after `PrepareForBuild`: + +```xml + + + CoreCompile + + + + + +``` + +#### Metadata Supported on AggregateConfigInput Items + +| Metadata | Description | Required | Default | +|----------|-------------|----------|---------| +| `OutputFile` | The output file path where the merged result will be saved. | No | `$(AggregateConfigOutputPath)%(FileName).json` | +| `OutputType` | The output file type (`Json`, `Yaml`, `Arm`, `Xml`). | No | `Json` | +| `InputType` | The input file type (`Json`, `Yaml`, `Arm`, `Xml`). | No | `Yaml` | +| `AddSourceProperty` | Whether to add source file name to merged objects (`true`/`false`). | No | `false` | + +The target batches items by `OutputFile`: every item that shares an `OutputFile` is merged in one task invocation, and the task merges all files of the given `InputType` in the directory that contains those items. Items that share an `OutputFile` should therefore live in the same directory. + +### Direct Task Invocation (Legacy) + +In your `.csproj` file, use the task to aggregate YAML files and output them in a specific format. Here’s an example of aggregating YAML files and generating JSON output: + +```xml + + + + + + + +``` + +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. + +### ARM Template Parameters Output Example + +You can also generate Azure ARM template parameters. Here's how to modify the configuration to output in the ARM parameter format: + +```xml + + + + + + + +``` + +### YAML Output Example + +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. + +```xml + + + + + + + + + + + + +``` + +In this example: +- The generated output file `output.json` is embedded in the resulting assembly as a resource. +- You can access this resource programmatically using the `System.Reflection` API. + +## Example YAML Input + +Assume you have the following YAML files in the `Configs` directory: + +```yaml +resources: + - id: "Resource1" + type: "Compute" + description: "Main compute resource" +``` + +```yaml +resources: + - id: "Resource2" + type: "Storage" + description: "Storage resource" +``` + +### Output JSON Example + +```json +{ + "resources": [ + { + "id": "Resource1", + "type": "Compute", + "description": "Main compute resource", + "source": "file1" + }, + { + "id": "Resource2", + "type": "Storage", + "description": "Storage resource", + "source": "file2" + } + ], + "ResourceGroup": "TestRG", + "Environment": "Production" +} +``` + +### ARM Parameter Output Example + +```json +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "resources": { + "type": "array", + "value": [ + { + "id": "Resource1", + "type": "Compute", + "description": "Main compute resource", + "source": "file1" + }, + { + "id": "Resource2", + "type": "Storage", + "description": "Storage resource", + "source": "file2" + } + ] + }, + "ResourceGroup": { + "type": "string", + "value": "TestRG" + }, + "Environment": { + "type": "string", + "value": "Production" + } + } +} +``` + +## Accessing Embedded Resources in C# Assemblies + +Embedding resources such as configuration files into your assembly allows you to package all necessary data within a single executable or library. + +In the following example, we'll demonstrate how to read and deserialize an embedded resource at runtime. + +### Embedded resource + +Consider the following YML configuration files that you want to merge and embed into your assembly: + +**configs/global.yml** +```yml +enabled: true +``` + +**configs/prod.yml** +```yml +environment: Production +``` + +### Project file reference + +Your project should contain a reference similar to below: + +**application.csproj** +```xml + + + + + + + +``` + +### Reading the embedded resource + +To access and deserialize the embedded JSON resource, use the following method: + +**application.cs** +```csharp +using System; +using System.IO; +using System.Reflection; +using System.Text.Json; + +public static T LoadFromEmbeddedResource(string resourceName) +{ + var assembly = Assembly.GetExecutingAssembly(); + using var stream = assembly.GetManifestResourceStream(resourceName) + ?? throw new FileNotFoundException($"Resource '{resourceName}' not found in assembly."); + + return JsonSerializer.Deserialize(stream, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }) ?? throw new InvalidOperationException("Failed to deserialize resource."); +} +``` + +### Defining the Configuration Class + +Create a class that matches the structure of your configuration: + +```csharp +public class AppConfig +{ + public bool Enabled { get; set; } + public string Environment { get; set; } +} +``` + +### Loading and Using the Configuration + +You can now load and use the configuration data as follows: + +```csharp +var applicationConfig = LoadFromEmbeddedResource("YourAssemblyName.out.output.json"); + +bool enabled = applicationConfig.Enabled; +Console.WriteLine($"Enabled: {enabled}"); // Outputs "True" + +string environment = applicationConfig.Environment; +Console.WriteLine($"Environment: {environment}"); // Outputs "Production" +``` + +**Note:** Replace `"YourAssemblyName.out.output.json"` with the actual resource name, which typically includes the assembly name, output folder, and the output file name. + +### Finding the Correct Resource Name + +If you're unsure about the exact resource name, you can retrieve all resource names in the assembly by adding the following code and inspecting the output: + +```csharp +string[] resourceNames = Assembly.GetExecutingAssembly().GetManifestResourceNames(); +foreach (var name in resourceNames) +{ + Console.WriteLine(name); +} +``` + +This will list all embedded resources, allowing you to confirm the correct name to use when loading the resource. + +## Development + +This project uses `dirs.proj` files with the Microsoft.Build.Traversal SDK for project organization instead of traditional solution files. This approach provides better performance and more flexibility for build scenarios. + +### Generating Solution Files + +If you need Visual Studio solution files for development, you can generate them using the [Microsoft.VisualStudio.SlnGen](https://github.com/microsoft/slngen) global tool: + +```bash +# Install the slngen global tool +dotnet tool install --global Microsoft.VisualStudio.SlnGen + +# Generate solution files from dirs.proj files +slngen src/dirs.proj --folders true +slngen test/dirs.proj --folders true + +# Or generate a solution for the entire repository +slngen dirs.proj --folders true +``` + +The generated solution files will include all projects referenced by the `dirs.proj` files and maintain the folder structure for easy navigation in Visual Studio. + +## License + +This project is licensed under the MIT License. See the [LICENSE](https://github.com/richardsondev/AggregateConfigBuildTask/blob/main/LICENSE) file for details. + +## Third-Party Libraries + +This project leverages the following third-party libraries that are bundled with the package: + +- **[YamlDotNet](https://github.com/aaubry/YamlDotNet)**\ + __Copyright (c) 2008, 2009, 2010, 2011, 2012, 2013, 2014 Antoine Aubry and contributors__\ + Used for YAML serialization and deserialization. YamlDotNet is distributed under the MIT License. For detailed information, refer to the [YamlDotNet License](https://github.com/aaubry/YamlDotNet/blob/master/LICENSE.txt). + +- **[YamlDotNet.System.Text.Json](https://github.com/IvanJosipovic/YamlDotNet.System.Text.Json)**\ + __Copyright (c) 2022 Ivan Josipovic__\ + Facilitates type handling for YAML serialization and deserialization, enhancing compatibility with System.Text.Json. This library is also distributed under the MIT License. For more details, see the [YamlDotNet.System.Text.Json License](https://github.com/IvanJosipovic/YamlDotNet.System.Text.Json/blob/main/LICENSE). + +## Contributing + +Contributions are welcome! Feel free to submit issues or pull requests on [GitHub](https://github.com/richardsondev/AggregateConfigBuildTask). diff --git a/docfx.json b/docfx.json index 4fcba6b..4924641 100644 --- a/docfx.json +++ b/docfx.json @@ -43,6 +43,7 @@ { "files": [ "README.md", + "MIGRATION_GUIDE.md", "toc.yml" ], "dest": "./" diff --git a/examples/ItemBasedExample.csproj b/examples/ItemBasedExample.csproj new file mode 100644 index 0000000..81afc61 --- /dev/null +++ b/examples/ItemBasedExample.csproj @@ -0,0 +1,95 @@ + + + + net10.0 + Library + + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + CoreCompile + + + + + $(MSBuildProjectDirectory)\generated\ + + + + + + + + + + + + + + diff --git a/examples/configs/app.yml b/examples/configs/app.yml new file mode 100644 index 0000000..dc23297 --- /dev/null +++ b/examples/configs/app.yml @@ -0,0 +1,3 @@ +app: + name: Example App + version: 1.0.0 diff --git a/examples/configs/databases/databases.yml b/examples/configs/databases/databases.yml new file mode 100644 index 0000000..72b13cf --- /dev/null +++ b/examples/configs/databases/databases.yml @@ -0,0 +1,3 @@ +databases: + - name: primary + engine: postgres diff --git a/examples/configs/environment/dev.yml b/examples/configs/environment/dev.yml new file mode 100644 index 0000000..78c2097 --- /dev/null +++ b/examples/configs/environment/dev.yml @@ -0,0 +1,3 @@ +servers: + - name: web-dev + url: https://dev.example.com diff --git a/examples/configs/environment/prod.yml b/examples/configs/environment/prod.yml new file mode 100644 index 0000000..f52ca9f --- /dev/null +++ b/examples/configs/environment/prod.yml @@ -0,0 +1,3 @@ +servers: + - name: web-prod + url: https://www.example.com diff --git a/examples/configs/legacy/settings.yml b/examples/configs/legacy/settings.yml new file mode 100644 index 0000000..22c6e07 --- /dev/null +++ b/examples/configs/legacy/settings.yml @@ -0,0 +1,3 @@ +settings: + - key: legacyMode + value: true diff --git a/global.json b/global.json index 286d9b8..866c081 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "9.0.301", + "version": "10.0.100", "rollForward": "latestFeature" }, "msbuild-sdks": { diff --git a/src/Task/AggregateConfig.cs b/src/Task/AggregateConfig.cs index 6ca8204..d0ac8f4 100644 --- a/src/Task/AggregateConfig.cs +++ b/src/Task/AggregateConfig.cs @@ -1,158 +1,158 @@ -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")] - -namespace AggregateConfigBuildTask -{ - /// - /// Represents a task that aggregates configuration files from a directory and outputs a merged file with optional modifications. - /// - public class AggregateConfig : Task - { - private readonly IFileSystem fileSystem; - private ITaskLogger logger; - - /// - /// The directory path where input files are located. This property is required. - /// - [Required] - public string InputDirectory { get; set; } - - /// - /// The type of input file type to be processed from . If not specified, the default type is used. - /// - public string InputType { get; set; } - - /// - /// The output file path where the merged result will be saved. This property is required. - /// - [Required] - public string OutputFile { get; set; } - - /// - /// The type of the output file type to be created from . This property is required. - /// - [Required] - public string OutputType { get; set; } - - /// - /// Specifies whether the source property (i.e., the file name) should be added to each merged object. - /// - public bool AddSourceProperty { get; set; } - - /// - /// An array of additional properties that can be included in the output. These are user-specified key-value pairs. - /// - public ITaskItem[] AdditionalProperties { get; set; } - - /// - /// Gets or sets whether quiet mode is enabled. When enabled, the logger will suppress non-critical messages. - /// - public bool IsQuietMode - { - get - { - return logger is QuietTaskLogger; - } - set - { - logger = value && !(logger is QuietTaskLogger) ? new QuietTaskLogger(Log) : logger; - } - } - - /// - /// Initializes a new instance of the class with default file system and logger. - /// - public AggregateConfig() - { - this.fileSystem = new FileSystem(); - this.logger = new TaskLogger(Log); - } - - internal AggregateConfig(IFileSystem fileSystem, ITaskLogger logger) - { - this.fileSystem = fileSystem; - this.logger = logger; - } - - /// - /// The entry point for the task. - /// - /// A boolean that is true if processing was successful. - public override bool Execute() - { - try - { - EmitHeader(); - - InputDirectory = Path.GetFullPath(InputDirectory); - OutputFile = Path.GetFullPath(OutputFile); - - if (!Enum.TryParse(OutputType, true, out FileType outputType) || - !Enum.IsDefined(typeof(FileType), outputType)) - { - logger.LogError(message: "Invalid FileType: {0}. Available options: {1}", OutputType, string.Join(", ", Enum.GetNames(typeof(FileType)))); - return false; - } - - FileType inputType = FileType.Yaml; - if (!string.IsNullOrEmpty(InputType) && - (!Enum.TryParse(InputType, true, out inputType) || !Enum.IsDefined(typeof(FileType), inputType))) - { - logger.LogError(message: "Invalid FileType: {0}. Available options: {1}", InputType, string.Join(", ", Enum.GetNames(typeof(FileType)))); - return false; - } - - logger.LogMessage(MessageImportance.High, "Aggregating {0} to {1} in folder {2}", inputType, outputType, InputDirectory); - - string directoryPath = Path.GetDirectoryName(OutputFile); - if (!fileSystem.DirectoryExists(directoryPath)) - { - fileSystem.CreateDirectory(directoryPath); - } - - var finalResult = ObjectManager.MergeFileObjects(InputDirectory, inputType, AddSourceProperty, fileSystem, logger).GetAwaiter().GetResult(); - - if (finalResult == null) - { - logger.LogError(message: "No input was found! Check the input directory."); - return false; - } - - var additionalPropertiesDictionary = JsonHelper.ParseAdditionalProperties(AdditionalProperties); - finalResult = ObjectManager.InjectAdditionalProperties(finalResult, additionalPropertiesDictionary, logger).GetAwaiter().GetResult(); - - var writer = FileHandlerFactory.GetFileHandlerForType(fileSystem, outputType); - writer.WriteOutput(finalResult, OutputFile); - logger.LogMessage(MessageImportance.High, "Wrote aggregated configuration file to {0}", OutputFile); - - return true; - } - catch (Exception ex) - { - logger.LogError(message: "An unknown exception occurred: {0}", ex.Message); - logger.LogErrorFromException(ex, true, true, null); - return false; - } - } - - private void EmitHeader() - { - var assembly = Assembly.GetExecutingAssembly(); - var informationalVersion = assembly - .GetCustomAttribute()? - .InformationalVersion; - - logger.LogMessage(MessageImportance.Normal, $"AggregateConfig Version: {informationalVersion}"); - } - } -} +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")] + +namespace AggregateConfigBuildTask +{ + /// + /// Represents a task that aggregates configuration files from a directory and outputs a merged file with optional modifications. + /// + public class AggregateConfig : Task + { + private readonly IFileSystem fileSystem; + private ITaskLogger logger; + + /// + /// The directory path where input files are located. This property is required. + /// + [Required] + public string InputDirectory { get; set; } + + /// + /// The type of input file type to be processed from . If not specified, the default type is used. + /// + public string InputType { get; set; } + + /// + /// The output file path where the merged result will be saved. This property is required. + /// + [Required] + public string OutputFile { get; set; } + + /// + /// The type of the output file type to be created from . This property is required. + /// + [Required] + public string OutputType { get; set; } + + /// + /// Specifies whether the source property (i.e., the file name) should be added to each merged object. + /// + public bool AddSourceProperty { get; set; } + + /// + /// An array of additional properties that can be included in the output. These are user-specified key-value pairs. + /// + public ITaskItem[] AdditionalProperties { get; set; } + + /// + /// Gets or sets whether quiet mode is enabled. When enabled, the logger will suppress non-critical messages. + /// + public bool IsQuietMode + { + get + { + return logger is QuietTaskLogger; + } + set + { + logger = value && !(logger is QuietTaskLogger) ? new QuietTaskLogger(Log) : logger; + } + } + + /// + /// Initializes a new instance of the class with default file system and logger. + /// + public AggregateConfig() + { + this.fileSystem = new FileSystem(); + this.logger = new TaskLogger(Log); + } + + internal AggregateConfig(IFileSystem fileSystem, ITaskLogger logger) + { + this.fileSystem = fileSystem; + this.logger = logger; + } + + /// + /// The entry point for the task. + /// + /// A boolean that is true if processing was successful. + public override bool Execute() + { + try + { + EmitHeader(); + + InputDirectory = Path.GetFullPath(InputDirectory); + OutputFile = Path.GetFullPath(OutputFile); + + if (!Enum.TryParse(OutputType, true, out FileType outputType) || + !Enum.IsDefined(typeof(FileType), outputType)) + { + logger.LogError(message: "Invalid FileType: {0}. Available options: {1}", OutputType, string.Join(", ", Enum.GetNames(typeof(FileType)))); + return false; + } + + FileType inputType = FileType.Yaml; + if (!string.IsNullOrEmpty(InputType) && + (!Enum.TryParse(InputType, true, out inputType) || !Enum.IsDefined(typeof(FileType), inputType))) + { + logger.LogError(message: "Invalid FileType: {0}. Available options: {1}", InputType, string.Join(", ", Enum.GetNames(typeof(FileType)))); + return false; + } + + logger.LogMessage(MessageImportance.High, "Aggregating {0} to {1} in folder {2}", inputType, outputType, InputDirectory); + + string directoryPath = Path.GetDirectoryName(OutputFile); + if (!fileSystem.DirectoryExists(directoryPath)) + { + fileSystem.CreateDirectory(directoryPath); + } + + var finalResult = ObjectManager.MergeFileObjects(InputDirectory, inputType, AddSourceProperty, fileSystem, logger).GetAwaiter().GetResult(); + + if (finalResult == null) + { + logger.LogError(message: "No input was found! Check the input directory."); + return false; + } + + var additionalPropertiesDictionary = JsonHelper.ParseAdditionalProperties(AdditionalProperties); + finalResult = ObjectManager.InjectAdditionalProperties(finalResult, additionalPropertiesDictionary, logger).GetAwaiter().GetResult(); + + var writer = FileHandlerFactory.GetFileHandlerForType(fileSystem, outputType); + writer.WriteOutput(finalResult, OutputFile).GetAwaiter().GetResult(); + logger.LogMessage(MessageImportance.High, "Wrote aggregated configuration file to {0}", OutputFile); + + return true; + } + catch (Exception ex) + { + logger.LogError(message: "An unknown exception occurred: {0}", ex.Message); + logger.LogErrorFromException(ex, true, true, null); + return false; + } + } + + private void EmitHeader() + { + var assembly = Assembly.GetExecutingAssembly(); + var informationalVersion = assembly + .GetCustomAttribute()? + .InformationalVersion; + + logger.LogMessage(MessageImportance.Normal, $"AggregateConfig Version: {informationalVersion}"); + } + } +} diff --git a/src/Task/AggregateConfigBuildTask.csproj b/src/Task/AggregateConfigBuildTask.csproj index e2413bf..42df960 100644 --- a/src/Task/AggregateConfigBuildTask.csproj +++ b/src/Task/AggregateConfigBuildTask.csproj @@ -58,7 +58,22 @@ - + + + + + + + + + + + + + + + + licenses/LICENSE diff --git a/src/Task/FileHandlers/ArmParametersFileHandler.cs b/src/Task/FileHandlers/ArmParametersFileHandler.cs index 7c2148f..894c7b6 100644 --- a/src/Task/FileHandlers/ArmParametersFileHandler.cs +++ b/src/Task/FileHandlers/ArmParametersFileHandler.cs @@ -51,7 +51,7 @@ public async ValueTask ReadInput(string inputPath) } /// - public void WriteOutput(JsonElement? mergedData, string outputPath) + public async Task WriteOutput(JsonElement? mergedData, string outputPath) { if (mergedData.HasValue && mergedData.Value.ValueKind == JsonValueKind.Object) { @@ -76,7 +76,7 @@ public void WriteOutput(JsonElement? mergedData, string outputPath) ["parameters"] = parameters }; var jsonContent = JsonSerializer.Serialize(armTemplate, jsonOptions); - fileSystem.WriteAllText(outputPath, jsonContent); + await fileSystem.WriteAllTextAsync(outputPath, jsonContent).ConfigureAwait(false); } else { diff --git a/src/Task/FileHandlers/FileHandlerFactory.cs b/src/Task/FileHandlers/FileHandlerFactory.cs index e9c8d1e..ff4882c 100644 --- a/src/Task/FileHandlers/FileHandlerFactory.cs +++ b/src/Task/FileHandlers/FileHandlerFactory.cs @@ -25,6 +25,8 @@ internal static IFileHandler GetFileHandlerForType(IFileSystem fileSystem, FileT return new YamlFileHandler(fileSystem); case FileType.Arm: return new ArmParametersFileHandler(fileSystem); + case FileType.Xml: + return new XmlFileHandler(fileSystem); default: throw new ArgumentException("Unsupported format", nameof(format)); } @@ -46,6 +48,8 @@ internal static List GetExpectedFileExtensions(FileType inputType) return new List { ".yml", ".yaml" }; case FileType.Arm: return new List { ".json" }; + case FileType.Xml: + return new List { ".xml" }; default: throw new ArgumentException("Unsupported input type", nameof(inputType)); } diff --git a/src/Task/FileHandlers/FileType.cs b/src/Task/FileHandlers/FileType.cs index eb8a3c7..676e501 100644 --- a/src/Task/FileHandlers/FileType.cs +++ b/src/Task/FileHandlers/FileType.cs @@ -29,5 +29,10 @@ public enum FileType /// Alias for the file type, for files with the .yaml extension. /// Yaml = Yml, + + /// + /// Represents an eXtensible Markup Language (XML) file type. + /// + Xml = 3, } } diff --git a/src/Task/FileHandlers/IFileHandler.cs b/src/Task/FileHandlers/IFileHandler.cs index 8a43edf..bfc0ded 100644 --- a/src/Task/FileHandlers/IFileHandler.cs +++ b/src/Task/FileHandlers/IFileHandler.cs @@ -21,6 +21,7 @@ public interface IFileHandler /// /// The intermediate data in format. Can be null. /// The path to the output file where the data will be written. - void WriteOutput(JsonElement? mergedData, string outputPath); + /// A that is marked completed when the write completes. + Task WriteOutput(JsonElement? mergedData, string outputPath); } } diff --git a/src/Task/FileHandlers/JsonFileHandler.cs b/src/Task/FileHandlers/JsonFileHandler.cs index 4d1c6ab..22b04f4 100644 --- a/src/Task/FileHandlers/JsonFileHandler.cs +++ b/src/Task/FileHandlers/JsonFileHandler.cs @@ -25,10 +25,10 @@ public async ValueTask ReadInput(string inputPath) } /// - public void WriteOutput(JsonElement? mergedData, string outputPath) + public Task WriteOutput(JsonElement? mergedData, string outputPath) { var jsonContent = JsonSerializer.Serialize(mergedData, jsonOptions); - fileSystem.WriteAllText(outputPath, jsonContent); + return fileSystem.WriteAllTextAsync(outputPath, jsonContent); } } } diff --git a/src/Task/FileHandlers/XmlFileHandler.cs b/src/Task/FileHandlers/XmlFileHandler.cs new file mode 100644 index 0000000..97ae8b3 --- /dev/null +++ b/src/Task/FileHandlers/XmlFileHandler.cs @@ -0,0 +1,188 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using System.Xml; +using System.Xml.Linq; + +namespace AggregateConfigBuildTask.FileHandlers +{ + /// + public class XmlFileHandler : IFileHandler + { + private readonly IFileSystem fileSystem; + private readonly JsonSerializerOptions jsonOptions = new JsonSerializerOptions { WriteIndented = true }; + + internal XmlFileHandler(IFileSystem fileSystem) + { + this.fileSystem = fileSystem; + } + + /// + public async ValueTask ReadInput(string inputPath) + { + using (var xmlStream = fileSystem.OpenRead(inputPath)) + { + // Load XML document + var xmlDoc = XDocument.Load(xmlStream); + + // Convert XML to JSON string + var jsonObject = ConvertXmlElementToJsonObject(xmlDoc.Root); + + // Serialize the JSON object to JsonElement + var jsonContent = JsonSerializer.Serialize(jsonObject, jsonOptions); + return await Task.FromResult(JsonSerializer.Deserialize(jsonContent)).ConfigureAwait(false); + } + } + + /// + public async Task WriteOutput(JsonElement? mergedData, string outputPath) + { + if (mergedData.HasValue) + { + // Convert JsonElement back to XML + var xmlElement = ConvertJsonToXmlElement(mergedData.Value); + + // Save the XML to the file + var xmlDocument = new XDocument(xmlElement); + var settings = new XmlWriterSettings { Indent = true, NewLineChars = Environment.NewLine }; + using (var writer = new Utf8StringWriter()) + { + using (var xmlWriter = XmlWriter.Create(writer, settings)) + { + xmlDocument.Save(xmlWriter); + } + + await fileSystem.WriteAllTextAsync(outputPath, writer.ToString()).ConfigureAwait(false); + } + } + } + + /// + /// A that reports UTF-8 so the XML declaration matches the encoding used when the text is written to disk. + /// + private sealed class Utf8StringWriter : StringWriter + { + public override Encoding Encoding => Encoding.UTF8; + } + + /// + /// Converts an element to a JSON object: attributes become "@name" properties, child elements become + /// properties keyed by their name, repeated child names become arrays, and text alongside attributes becomes "#text". + /// + /// The element to convert. + /// The JSON object representing the element. + private static JsonObject ConvertXmlElementToJsonObject(XElement xmlElement) + { + var jsonObject = new JsonObject(); + + foreach (var attribute in xmlElement.Attributes()) + { + jsonObject.Add("@" + attribute.Name.LocalName, JsonValue.Create(attribute.Value)); + } + + if (!xmlElement.HasElements) + { + jsonObject.Add("#text", JsonValue.Create(xmlElement.Value)); + return jsonObject; + } + + foreach (var group in xmlElement.Elements().GroupBy(child => child.Name.LocalName, StringComparer.Ordinal)) + { + var children = group.ToList(); + if (children.Count == 1) + { + jsonObject.Add(group.Key, ConvertXmlElementToJson(children[0])); + continue; + } + + var array = new JsonArray(); + foreach (var child in children) + { + array.Add(ConvertXmlElementToJson(child)); + } + + jsonObject.Add(group.Key, array); + } + + return jsonObject; + } + + /// + /// Converts a non-root element to a JSON node. A leaf element without attributes becomes a string, and an element + /// whose children all carry its own name becomes an array (the shape produces for JSON arrays). + /// + /// The element to convert. + /// The JSON node representing the element. + private static JsonNode ConvertXmlElementToJson(XElement xmlElement) + { + if (!xmlElement.HasAttributes && !xmlElement.HasElements) + { + return JsonValue.Create(xmlElement.Value); + } + + var children = xmlElement.Elements().ToList(); + if (!xmlElement.HasAttributes && children.Count > 0 && children.TrueForAll(child => string.Equals(child.Name.LocalName, xmlElement.Name.LocalName, StringComparison.Ordinal))) + { + var array = new JsonArray(); + foreach (var child in children) + { + array.Add(ConvertXmlElementToJson(child)); + } + + return array; + } + + return ConvertXmlElementToJsonObject(xmlElement); + } + + private static XElement ConvertJsonToXmlElement(JsonElement jsonElement, string elementName = "root") + { + // Initialize the XElement with the provided element name. + var xmlElement = new XElement(elementName); + + if (jsonElement.ValueKind == JsonValueKind.Object) + { + foreach (var property in jsonElement.EnumerateObject()) + { + if (property.Name.StartsWith("@", StringComparison.OrdinalIgnoreCase)) + { + // Handle attributes + var attributeName = property.Name.Substring(1); + xmlElement.SetAttributeValue(attributeName, property.Value.GetString()); + } + else if (property.Name.Equals("#text", StringComparison.OrdinalIgnoreCase)) + { + // Handle text value + xmlElement.Value = property.Value.GetString(); + } + else + { + // Handle child elements - recursively convert them + var childElement = ConvertJsonToXmlElement(property.Value, property.Name); + xmlElement.Add(childElement); + } + } + } + else if (jsonElement.ValueKind == JsonValueKind.Array) + { + // Handle arrays by creating repeated elements with the same name + foreach (var item in jsonElement.EnumerateArray()) + { + var childElement = ConvertJsonToXmlElement(item, elementName); + xmlElement.Add(childElement); + } + } + else + { + // Handle primitive values (e.g., numbers, strings, booleans) + xmlElement.Value = jsonElement.ToString(); + } + + return xmlElement; + } + } +} diff --git a/src/Task/FileHandlers/YamlFileHandler.cs b/src/Task/FileHandlers/YamlFileHandler.cs index 3c86db9..ca632cd 100644 --- a/src/Task/FileHandlers/YamlFileHandler.cs +++ b/src/Task/FileHandlers/YamlFileHandler.cs @@ -35,7 +35,7 @@ public async ValueTask ReadInput(string inputPath) } /// - public void WriteOutput(JsonElement? mergedData, string outputPath) + public Task WriteOutput(JsonElement? mergedData, string outputPath) { var serializer = new SerializerBuilder() .WithNamingConvention(CamelCaseNamingConvention.Instance) @@ -43,7 +43,7 @@ public void WriteOutput(JsonElement? mergedData, string outputPath) .WithTypeInspector(x => new SystemTextJsonTypeInspector(x)) .Build(); var yamlContent = serializer.Serialize(mergedData); - fileSystem.WriteAllText(outputPath, yamlContent); + return fileSystem.WriteAllTextAsync(outputPath, yamlContent); } } } diff --git a/src/Task/FileSystem/FileSystem.cs b/src/Task/FileSystem/FileSystem.cs index 3fd4c99..85eccb2 100644 --- a/src/Task/FileSystem/FileSystem.cs +++ b/src/Task/FileSystem/FileSystem.cs @@ -1,4 +1,5 @@ using System.IO; +using System.Threading.Tasks; namespace AggregateConfigBuildTask { @@ -11,27 +12,12 @@ public string[] GetFiles(string path, string searchPattern) } /// - public string[] ReadAllLines(string path) + public async Task WriteAllTextAsync(string path, string text) { - return File.ReadAllLines(path); - } - - /// - public string ReadAllText(string path) - { - return File.ReadAllText(path); - } - - /// - public void WriteAllText(string path, string text) - { - File.WriteAllText(path, text); - } - - /// - public bool FileExists(string path) - { - return File.Exists(path); + using (var writer = new StreamWriter(path)) + { + await writer.WriteAsync(text).ConfigureAwait(false); + } } /// diff --git a/src/Task/FileSystem/IFileSystem.cs b/src/Task/FileSystem/IFileSystem.cs index da2e91b..805019f 100644 --- a/src/Task/FileSystem/IFileSystem.cs +++ b/src/Task/FileSystem/IFileSystem.cs @@ -1,4 +1,5 @@ using System.IO; +using System.Threading.Tasks; namespace AggregateConfigBuildTask { @@ -15,35 +16,12 @@ internal interface IFileSystem /// An array of file paths that match the specified search pattern. string[] GetFiles(string path, string searchPattern); - /// - /// Reads all lines from the specified file. - /// - /// The path of the file to read. - /// A string containing all the lines from the file. - string[] ReadAllLines(string path); - - /// - /// Reads all text from the specified file. - /// - /// The path of the file to read. - /// A string containing all the text from the file. - string ReadAllText(string path); - /// /// Writes the specified text to the specified file, overwriting the file if it already exists. /// /// The path of the file to write to. /// The text to write to the file. - void WriteAllText(string path, string text); - - /// - /// Checks if the specified file exists at the given path. - /// - /// The full path of the file to check for existence. - /// - /// true if the file exists; otherwise, false. - /// - bool FileExists(string path); + Task WriteAllTextAsync(string path, string text); /// /// Checks whether the specified directory exists in the virtual file system. diff --git a/src/Task/ObjectManager.cs b/src/Task/ObjectManager.cs index eaa888c..c5cf75b 100644 --- a/src/Task/ObjectManager.cs +++ b/src/Task/ObjectManager.cs @@ -68,7 +68,7 @@ await fileGroups.ForEachAsync(Environment.ProcessorCount, catch (Exception ex) { hasError = true; - log.LogError(message: "Could not parse {0}: {1}", file, ex.Message); + log.LogError(message: "Could not parse {0}: {1}", messageArgs: new object[] { file, ex.Message }); log.LogErrorFromException(ex, true, true, file); continue; } diff --git a/src/Task/build/AggregateConfigBuildTask.props b/src/Task/build/AggregateConfigBuildTask.props new file mode 100644 index 0000000..f3cbc51 --- /dev/null +++ b/src/Task/build/AggregateConfigBuildTask.props @@ -0,0 +1,16 @@ + + + + + $(MSBuildThisFileDirectory)..\tasks\netstandard2.0\AggregateConfigBuildTask.dll + + + + + + + + + + + diff --git a/src/Task/build/AggregateConfigBuildTask.targets b/src/Task/build/AggregateConfigBuildTask.targets index 9e5c30d..db1a4c2 100644 --- a/src/Task/build/AggregateConfigBuildTask.targets +++ b/src/Task/build/AggregateConfigBuildTask.targets @@ -1,6 +1,77 @@ - - - - - + + + + + + + + + + + + PrepareForBuild + + + $(OutputPath) + + + + + + + Json + + + $(AggregateConfigOutputPath)%(Filename).json + + + + + + + + + + + + + + diff --git a/src/Task/buildMultiTargeting/AggregateConfigBuildTask.props b/src/Task/buildMultiTargeting/AggregateConfigBuildTask.props new file mode 100644 index 0000000..8f28901 --- /dev/null +++ b/src/Task/buildMultiTargeting/AggregateConfigBuildTask.props @@ -0,0 +1,3 @@ + + + diff --git a/src/Task/buildMultiTargeting/AggregateConfigBuildTask.targets b/src/Task/buildMultiTargeting/AggregateConfigBuildTask.targets new file mode 100644 index 0000000..8ebc723 --- /dev/null +++ b/src/Task/buildMultiTargeting/AggregateConfigBuildTask.targets @@ -0,0 +1,3 @@ + + + diff --git a/src/UnitTests/Data/DemoData.cs b/src/UnitTests/Data/DemoData.cs new file mode 100644 index 0000000..d6a09b1 --- /dev/null +++ b/src/UnitTests/Data/DemoData.cs @@ -0,0 +1,114 @@ +using System; +using System.Diagnostics.CodeAnalysis; + +namespace AggregateConfigBuildTask.Tests.Unit +{ + internal static class DemoData + { + [SuppressMessage("Design", "MA0051:Method is too long", Justification = "Test data method containing sample data strings")] + public static string GetSampleDataForType(string type) + { + return type switch + { + "JSON" => """ +{ + "options": [ + { + "name": "Option 1", + "description": "First option", + "isTrue": true, + "number": 100, + "nested": [ + { + "name": "Nested option 1", + "description": "Nested first option", + "isTrue": true, + "number": 1001 + }, + { + "name": "Nested option 2", + "description": "Nested second option" + } + ] + } + ] +} +""", + + "ARM" => """ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "options": { + "type": "array", + "value": [ + { + "name": "Option 1", + "description": "First option", + "isTrue": true, + "number": 100, + "nested": [ + { + "name": "Nested option 1", + "description": "Nested first option", + "isTrue": true, + "number": 1002 + }, + { + "name": "Nested option 2", + "description": "Nested second option" + } + ] + } + ] + } + } +} +""", + + "YML" => @"options: +- name: Option 1 + description: First option + isTrue: true + number: 100 + nested: + - name: Nested option 1 + description: Nested first option + isTrue: true + number: 1003 + - name: Nested option 2 + description: Nested second option +", + + "XML" => """ + + + + + Option 1 + First option + True + 100 + + + Nested option 1 + Nested first option + True + 1002 + + + Nested option 2 + Nested second option + + + + + +""", + + _ => throw new InvalidOperationException($"Unknown type: {type}") + }; + } + } +} \ No newline at end of file diff --git a/src/UnitTests/TaskTestBase.cs b/src/UnitTests/TaskTestBase.cs index a78d19e..c1f87c9 100644 --- a/src/UnitTests/TaskTestBase.cs +++ b/src/UnitTests/TaskTestBase.cs @@ -2,7 +2,9 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Reflection; using System.Text; +using System.Threading.Tasks; using Microsoft.Build.Framework; @@ -19,7 +21,7 @@ public abstract class TaskTestBase private readonly StringComparison comparison = StringComparison.OrdinalIgnoreCase; private Mock mockLogger; - internal IFileSystem virtualFileSystem; + internal VirtualFileSystem virtualFileSystem; public TestContext TestContext { get; set; } @@ -37,24 +39,24 @@ public void Cleanup() foreach (var invocation in mockLogger.Invocations) { var methodName = invocation.Method.Name; - var arguments = string.Join(", ", invocation.Arguments); + var arguments = string.Join(", ", JsonConvert.SerializeObject(invocation.Arguments)); TestContext.WriteLine($"Logger call: {methodName}({arguments})"); } } [TestMethod] [Description("Test that YAML files are merged into correct JSON output.")] - public void ShouldGenerateJsonOutput() + public async Task ShouldGenerateJsonOutput() { // Arrange: Prepare sample YAML data in the mock file system. - virtualFileSystem.WriteAllText($"{testPath}\\file1.yml", @" + await virtualFileSystem.WriteAllTextAsync($"{testPath}\\file1.yml", @" options: - name: 'Option 1' - description: 'First option'"); - virtualFileSystem.WriteAllText($"{testPath}\\file2.yml", @" + description: 'First option'").ConfigureAwait(false); + await virtualFileSystem.WriteAllTextAsync($"{testPath}\\file2.yml", @" options: - name: 'Option 2' - description: 'Second option'"); + description: 'Second option'").ConfigureAwait(false); var task = new AggregateConfig(virtualFileSystem, mockLogger.Object) { @@ -70,7 +72,7 @@ public void ShouldGenerateJsonOutput() // Assert: Check that output was generated correctly. Assert.IsTrue(result); - string output = virtualFileSystem.ReadAllText($"{testPath}\\output.json"); + string output = await virtualFileSystem.ReadAllTextAsync($"{testPath}\\output.json").ConfigureAwait(false); var json = JsonConvert.DeserializeObject>(output); Assert.IsTrue(json.ContainsKey("options")); Assert.AreEqual(2, ((IEnumerable)json.GetValueOrDefault("options")).Count()); @@ -78,17 +80,17 @@ public void ShouldGenerateJsonOutput() [TestMethod] [Description("Test that YAML files are merged into correct ARM parameter output.")] - public void ShouldGenerateArmParameterOutput() + public async Task ShouldGenerateArmParameterOutput() { // Arrange: Prepare sample YAML data in the mock file system. - virtualFileSystem.WriteAllText($"{testPath}\\file1.yml", @" + await virtualFileSystem.WriteAllTextAsync($"{testPath}\\file1.yml", @" options: - name: 'Option 1' - description: 'First option'"); - virtualFileSystem.WriteAllText($"{testPath}\\file2.yml", @" + description: 'First option'").ConfigureAwait(false); + await virtualFileSystem.WriteAllTextAsync($"{testPath}\\file2.yml", @" options: - name: 'Option 2' - description: 'Second option'"); + description: 'Second option'").ConfigureAwait(false); // Create the task instance with the mock file system var task = new AggregateConfig(virtualFileSystem, mockLogger.Object) @@ -105,7 +107,7 @@ public void ShouldGenerateArmParameterOutput() // Assert: Check the ARM output structure Assert.IsTrue(result); - string output = virtualFileSystem.ReadAllText($"{testPath}\\output.parameters.json"); + string output = await virtualFileSystem.ReadAllTextAsync($"{testPath}\\output.parameters.json").ConfigureAwait(false); var armTemplate = JsonConvert.DeserializeObject>(output); Assert.IsTrue(armTemplate.ContainsKey("parameters")); @@ -116,20 +118,22 @@ public void ShouldGenerateArmParameterOutput() [TestMethod] [Description("Test that the source property is correctly added when AddSourceProperty is true.")] - public void ShouldAddSourceProperty() + [DynamicData(nameof(GetFileTypes), DynamicDataSourceType.Method)] + public async Task ShouldAddSourceProperty(FileType outputType) { // Arrange: Prepare sample YAML data with source property enabled. - virtualFileSystem.WriteAllText($"{testPath}\\file1.yml", @" + Guid outputFileName = Guid.NewGuid(); + await virtualFileSystem.WriteAllTextAsync($"{testPath}\\{outputFileName}.yml", @" options: - name: 'Option 1' - description: 'First option'"); + description: 'First option'").ConfigureAwait(false); // Create the task instance with the mock file system var task = new AggregateConfig(virtualFileSystem, mockLogger.Object) { InputDirectory = testPath, - OutputFile = testPath + @"\output.json", - OutputType = nameof(FileType.Json), + OutputFile = testPath + $@"\output.{(outputType == FileType.Arm ? "json" : outputType)}", + OutputType = outputType.ToString().ToUpperInvariant(), AddSourceProperty = true, BuildEngine = Mock.Of() }; @@ -139,24 +143,22 @@ public void ShouldAddSourceProperty() // Assert: Verify that the source property was added Assert.IsTrue(result); - string output = virtualFileSystem.ReadAllText($"{testPath}\\output.json"); - var json = JsonConvert.DeserializeObject>>>(output); - Assert.IsTrue(json["options"][0].ContainsKey("source")); - Assert.AreEqual("file1", json["options"][0]["source"]); + string output = await virtualFileSystem.ReadAllTextAsync($"{testPath}\\output.{(outputType == FileType.Arm ? "json" : outputType)}").ConfigureAwait(false); + StringAssert.Contains(output, outputFileName.ToString(), StringComparison.InvariantCulture); } [TestMethod] [Description("Test that the source property is correctly added for multiple files when AddSourceProperty is true.")] - public void ShouldAddSourcePropertyMultipleFiles() + public async Task ShouldAddSourcePropertyMultipleFiles() { // Arrange: Prepare sample YAML data with source property enabled. - virtualFileSystem.WriteAllText($"{testPath}\\file1.yml", @" + await virtualFileSystem.WriteAllTextAsync($"{testPath}\\file1.yml", @" options: - name: 'Option 1' description: 'First option' additionalOptions: - value: 'Good day'"); - virtualFileSystem.WriteAllText($"{testPath}\\file2.yml", @" + value: 'Good day'").ConfigureAwait(false); + await virtualFileSystem.WriteAllTextAsync($"{testPath}\\file2.yml", @" options: - name: 'Option 2' description: 'Second option' @@ -166,7 +168,7 @@ public void ShouldAddSourcePropertyMultipleFiles() value: 'Good night' text: - name: 'Text 1' - description: 'Text'"); + description: 'Text'").ConfigureAwait(false); // Create the task instance with the mock file system var task = new AggregateConfig(virtualFileSystem, mockLogger.Object) @@ -183,7 +185,7 @@ public void ShouldAddSourcePropertyMultipleFiles() // Assert: Verify that the source property was added Assert.IsTrue(result); - string output = virtualFileSystem.ReadAllText($"{testPath}\\output.json"); + string output = await virtualFileSystem.ReadAllTextAsync($"{testPath}\\output.json").ConfigureAwait(false); var json = JsonConvert.DeserializeObject>>>(output); Assert.IsTrue(OptionExistsWithSource(json["options"], "Option 1", "file1")); Assert.IsTrue(OptionExistsWithSource(json["options"], "Option 2", "file2")); @@ -195,13 +197,13 @@ public void ShouldAddSourcePropertyMultipleFiles() [Description("Test that additional properties are correctly added to the top level in JSON output.")] [DataRow(true, DisplayName = "Legacy properties")] [DataRow(false, DisplayName = "Modern properties")] - public void ShouldIncludeAdditionalPropertiesInJson(bool useLegacyAdditionalProperties) + public async Task ShouldIncludeAdditionalPropertiesInJson(bool useLegacyAdditionalProperties) { // Arrange: Prepare sample YAML data. - virtualFileSystem.WriteAllText($"{testPath}\\file1.yml", @" + await virtualFileSystem.WriteAllTextAsync($"{testPath}\\file1.yml", @" options: - name: 'Option 1' - description: 'First option'"); + description: 'First option'").ConfigureAwait(false); var task = new AggregateConfig(virtualFileSystem, mockLogger.Object) { @@ -222,7 +224,7 @@ public void ShouldIncludeAdditionalPropertiesInJson(bool useLegacyAdditionalProp // Assert: Verify additional properties are included Assert.IsTrue(result); - string output = virtualFileSystem.ReadAllText($"{testPath}\\output.json"); + string output = await virtualFileSystem.ReadAllTextAsync($"{testPath}\\output.json").ConfigureAwait(false); var json = JsonConvert.DeserializeObject>(output); Assert.AreEqual("TestRG", json["Group"]); @@ -240,13 +242,13 @@ public void ShouldIncludeAdditionalPropertiesInJson(bool useLegacyAdditionalProp [Description("Test that additional properties are correctly added to the ARM parameters output.")] [DataRow(true, DisplayName = "Legacy properties")] [DataRow(false, DisplayName = "Modern properties")] - public void ShouldIncludeAdditionalPropertiesInArmParameters(bool useLegacyAdditionalProperties) + public async Task ShouldIncludeAdditionalPropertiesInArmParameters(bool useLegacyAdditionalProperties) { // Arrange: Prepare sample YAML data. - virtualFileSystem.WriteAllText($"{testPath}\\file1.yml", @" + await virtualFileSystem.WriteAllTextAsync($"{testPath}\\file1.yml", @" options: - name: 'Option 1' - description: 'First option'"); + description: 'First option'").ConfigureAwait(false); var task = new AggregateConfig(virtualFileSystem, mockLogger.Object) { @@ -267,7 +269,7 @@ public void ShouldIncludeAdditionalPropertiesInArmParameters(bool useLegacyAddit // Assert: Verify additional properties are included in ARM output Assert.IsTrue(result); - string output = virtualFileSystem.ReadAllText($"{testPath}\\output.json"); + string output = await virtualFileSystem.ReadAllTextAsync($"{testPath}\\output.json").ConfigureAwait(false); var armTemplate = JsonConvert.DeserializeObject>(output); JObject parameters = (JObject)armTemplate["parameters"]; Assert.AreEqual("array", parameters.GetValue("options", comparison)["type"].ToString()); @@ -299,13 +301,13 @@ public void ShouldHandleEmptyDirectory() [TestMethod] [Description("Test that the task throws an error when it encounters invalid YAML format.")] - public void ShouldHandleInvalidYamlFormat() + public async Task ShouldHandleInvalidYamlFormat() { // Arrange: Add invalid YAML file to the mock file system. - virtualFileSystem.WriteAllText($"{testPath}\\invalid.yml", @" + await virtualFileSystem.WriteAllTextAsync($"{testPath}\\invalid.yml", @" options: - name: 'Option 1' - description: 'Unclosed value"); + description: 'Unclosed value").ConfigureAwait(false); var task = new AggregateConfig(virtualFileSystem, mockLogger.Object) { @@ -324,14 +326,14 @@ public void ShouldHandleInvalidYamlFormat() [TestMethod] [Description("Test that boolean input values are correctly treated as booleans in the output.")] - public void ShouldCorrectlyParseBooleanValues() + public async Task ShouldCorrectlyParseBooleanValues() { // Arrange: Prepare sample YAML data. - virtualFileSystem.WriteAllText($"{testPath}\\file1.yml", @" + await virtualFileSystem.WriteAllTextAsync($"{testPath}\\file1.yml", @" options: - name: 'Option 1' description: 'First option' - isEnabled: true"); + isEnabled: true").ConfigureAwait(false); var task = new AggregateConfig(virtualFileSystem, mockLogger.Object) { @@ -346,7 +348,7 @@ public void ShouldCorrectlyParseBooleanValues() // Assert: Verify additional properties are included in ARM output Assert.IsTrue(result); - string output = virtualFileSystem.ReadAllText($"{testPath}\\output.json"); + string output = await virtualFileSystem.ReadAllTextAsync($"{testPath}\\output.json").ConfigureAwait(false); var armTemplate = JsonConvert.DeserializeObject>(output); JObject parameters = (JObject)armTemplate["parameters"]; Assert.AreEqual("array", parameters.GetValue("options", comparison)["type"].ToString()); @@ -358,10 +360,10 @@ public void ShouldCorrectlyParseBooleanValues() [Description("Test that additional properties are correctly added to the ARM parameters output from JSON input.")] [DataRow(true, DisplayName = "Legacy properties")] [DataRow(false, DisplayName = "Modern properties")] - public void ShouldIncludeAdditionalPropertiesInJsonInput(bool useLegacyAdditionalProperties) + public async Task ShouldIncludeAdditionalPropertiesInJsonInput(bool useLegacyAdditionalProperties) { // Arrange: Prepare sample JSON data. - virtualFileSystem.WriteAllText($"{testPath}\\file1.json", """ + await virtualFileSystem.WriteAllTextAsync($"{testPath}\\file1.json", """ { "options": [ { @@ -371,7 +373,7 @@ public void ShouldIncludeAdditionalPropertiesInJsonInput(bool useLegacyAdditiona } ] } -"""); +""").ConfigureAwait(false); var task = new AggregateConfig(virtualFileSystem, mockLogger.Object) { @@ -393,7 +395,7 @@ public void ShouldIncludeAdditionalPropertiesInJsonInput(bool useLegacyAdditiona // Assert: Verify additional properties are included in ARM output Assert.IsTrue(result); - string output = virtualFileSystem.ReadAllText($"{testPath}\\output.json"); + string output = await virtualFileSystem.ReadAllTextAsync($"{testPath}\\output.json").ConfigureAwait(false); var armTemplate = JsonConvert.DeserializeObject>(output); JObject parameters = (JObject)armTemplate["parameters"]; Assert.AreEqual("TestRG", parameters.GetValue("Group", comparison)["value"].Value()); @@ -404,14 +406,65 @@ public void ShouldIncludeAdditionalPropertiesInJsonInput(bool useLegacyAdditiona Assert.AreEqual(true, parameters.GetValue("options", comparison)["value"].First()["isEnabled"].Value()); } + [TestMethod] + [Description("Test that additional properties are correctly added to the JSON output from XML input.")] + [DataRow(true, DisplayName = "Legacy properties")] + [DataRow(false, DisplayName = "Modern properties")] + public async Task ShouldIncludeAdditionalPropertiesInXmlInput(bool useLegacyAdditionalProperties) + { + // Arrange: Prepare sample XML data. + await virtualFileSystem.WriteAllTextAsync($"{testPath}\\file1.xml", """ + + + + Option 1 + First option + + +""").ConfigureAwait(false); + + var task = new AggregateConfig(virtualFileSystem, mockLogger.Object) + { + InputDirectory = testPath, + InputType = nameof(FileType.Xml), + OutputFile = testPath + @"\output.json", + OutputType = nameof(FileType.Json), + AddSourceProperty = true, + AdditionalProperties = new Dictionary(StringComparer.Ordinal) + { + { "Group", "TestRG" }, + { "Environment\\=Key", "Prod\\=West" } + }.CreateTaskItems(useLegacyAdditionalProperties), + BuildEngine = Mock.Of() + }; + + // Act: Execute the task + bool result = task.Execute(); + + // Assert: Verify additional properties are included + Assert.IsTrue(result); + string output = await virtualFileSystem.ReadAllTextAsync($"{testPath}\\output.json").ConfigureAwait(false); + var json = JsonConvert.DeserializeObject>(output); + Assert.AreEqual("TestRG", json["Group"]); + + if (useLegacyAdditionalProperties) + { + Assert.AreEqual("Prod=West", json["Environment=Key"]); + } + else + { + Assert.AreEqual("Prod\\=West", json["Environment\\=Key"]); + } + } + [TestMethod] [Description("Test that ARM parameters are correctly processed and additional properties are included in the output.")] [DataRow(true, DisplayName = "Legacy properties")] [DataRow(false, DisplayName = "Modern properties")] - public void ShouldIncludeAdditionalPropertiesInArmParameterFile(bool useLegacyAdditionalProperties) + public async Task ShouldIncludeAdditionalPropertiesInArmParameterFile(bool useLegacyAdditionalProperties) { // Arrange: Prepare ARM template parameter file data in 'file1.parameters.json'. - virtualFileSystem.WriteAllText($"{testPath}\\file1.parameters.json", """ + await virtualFileSystem.WriteAllTextAsync($"{testPath}\\file1.parameters.json", """ { "parameters": { "options": { @@ -426,7 +479,7 @@ public void ShouldIncludeAdditionalPropertiesInArmParameterFile(bool useLegacyAd } } } -"""); +""").ConfigureAwait(false); var task = new AggregateConfig(virtualFileSystem, mockLogger.Object) { @@ -448,7 +501,7 @@ public void ShouldIncludeAdditionalPropertiesInArmParameterFile(bool useLegacyAd // Assert: Verify additional properties are included in ARM output Assert.IsTrue(result); - string output = virtualFileSystem.ReadAllText($"{testPath}\\output.parameters.json"); + string output = await virtualFileSystem.ReadAllTextAsync($"{testPath}\\output.parameters.json").ConfigureAwait(false); var armTemplate = JsonConvert.DeserializeObject>(output); JObject parameters = (JObject)armTemplate["parameters"]; Assert.AreEqual("TestRG", parameters.GetValue("Group", comparison)["value"].Value()); @@ -462,7 +515,7 @@ public void ShouldIncludeAdditionalPropertiesInArmParameterFile(bool useLegacyAd [TestMethod] [Description("Stress test to verify the source property is correctly added for 1,000 files with 10 options each.")] [Timeout(60000)] - public void StressTest_ShouldAddSourcePropertyManyFiles() + public async Task StressTest_ShouldAddSourcePropertyManyFiles() { // Arrange: Prepare sample YAML data. const int totalFiles = 1_000; @@ -480,7 +533,7 @@ public void StressTest_ShouldAddSourcePropertyManyFiles() } // Write each YAML file to the mock file system - virtualFileSystem.WriteAllText($"{testPath}\\file{fileIndex}.yml", sb.ToString()); + await virtualFileSystem.WriteAllTextAsync($"{testPath}\\file{fileIndex}.yml", sb.ToString()).ConfigureAwait(false); } var task = new AggregateConfig(virtualFileSystem, mockLogger.Object) @@ -497,7 +550,7 @@ public void StressTest_ShouldAddSourcePropertyManyFiles() // Assert: Verify that the source property was added correctly for all files and options Assert.IsTrue(result); - string output = virtualFileSystem.ReadAllText($"{testPath}\\output.json"); + string output = await virtualFileSystem.ReadAllTextAsync($"{testPath}\\output.json").ConfigureAwait(false); var json = JsonConvert.DeserializeObject>>>(output); int optionIndexInTotal = 0; @@ -512,37 +565,31 @@ public void StressTest_ShouldAddSourcePropertyManyFiles() } [TestMethod] - [DataRow("arm", new[] { "json", "yml", "arm" }, DisplayName = "ARM -> JSON -> YAML -> ARM")] - [DataRow("arm", new[] { "yml", "json", "arm" }, DisplayName = "ARM -> YAML -> JSON -> ARM")] - [DataRow("json", new[] { "arm", "yml", "json" }, DisplayName = "JSON -> ARM -> YAML -> JSON")] - [DataRow("json", new[] { "yml", "arm", "json" }, DisplayName = "JSON -> YAML -> ARM -> JSON")] - [DataRow("yml", new[] { "arm", "json", "yml" }, DisplayName = "YAML -> ARM -> JSON -> YAML")] - [DataRow("yml", new[] { "json", "arm", "yml" }, DisplayName = "YAML -> JSON -> ARM -> YAML")] - [Description("Test that files are correctly translated between ARM, JSON, and YAML.")] - public void ShouldTranslateBetweenFormatsAndValidateNoDataLoss(string inputType, string[] steps) + [Description("Test that files are correctly translated between all supported FileTypes.")] + [DynamicData(nameof(GetFileTypeConversions), DynamicDataSourceType.Method, DynamicDataDisplayName = nameof(GetTestDisplayName))] + public async Task ShouldTranslateBetweenFormatsAndValidateNoDataLoss(string inputType, string[] steps, string _) { + ArgumentNullException.ThrowIfNull(inputType); Assert.IsTrue(steps?.Length > 0); // Setup input file - string inputFilePath = SetupInputFile(inputType); + string inputFilePath = await SetupInputFile(inputType).ConfigureAwait(false); // Execute translation chain var (finalInputPath, finalOutputType) = ExecuteTranslationChain(inputType, steps, inputFilePath); - // Execute final conversion back to original format - string finalOutputPath = ExecuteFinalConversion(inputType, finalInputPath, finalOutputType); - - // Validate no data loss - AssertNoDataLoss(inputFilePath, finalOutputPath, inputType); + // Execute final conversion back to original format (includes data loss validation) + await ExecuteFinalConversion(inputType, finalInputPath, finalOutputType, inputFilePath).ConfigureAwait(false); } - private string SetupInputFile(string inputType) + private async Task SetupInputFile(string inputType) { var inputDir = $"{testPath}\\input"; virtualFileSystem.CreateDirectory(inputDir); - var inputFilePath = $"{inputDir}\\input.{(string.Equals(inputType, "arm", StringComparison.Ordinal) ? "json" : inputType)}"; - virtualFileSystem.WriteAllText(inputFilePath, GetSampleDataForType(inputType)); + // Write the initial input file + var inputFilePath = $"{inputDir}\\input.{(inputType.Equals(nameof(FileType.Arm), StringComparison.OrdinalIgnoreCase) ? "json" : inputType)}"; + await virtualFileSystem.WriteAllTextAsync(inputFilePath, DemoData.GetSampleDataForType(inputType)).ConfigureAwait(false); return inputFilePath; } @@ -557,7 +604,7 @@ private string SetupInputFile(string inputType) { var outputType = steps[i]; var stepDir = $"{testPath}\\step{i + 1}"; - var stepOutputPath = $"{stepDir}\\output.{(string.Equals(outputType, "arm", StringComparison.Ordinal) ? "json" : outputType)}"; + var stepOutputPath = $"{stepDir}\\output.{(outputType.Equals(nameof(FileType.Arm), StringComparison.OrdinalIgnoreCase) ? "json" : outputType)}"; virtualFileSystem.CreateDirectory(stepDir); @@ -572,135 +619,92 @@ private string SetupInputFile(string inputType) return (previousInputPath, previousOutputType); } - private string ExecuteFinalConversion(string inputType, string previousInputPath, string previousOutputType) + private async Task ExecuteFinalConversion(string inputType, string previousInputPath, string previousOutputType, string originalInputPath) { // Final step: Convert the final output back to the original input type var finalDir = $"{testPath}\\final"; - var finalOutputPath = $"{finalDir}\\final_output.{(string.Equals(inputType, "arm", StringComparison.Ordinal) ? "json" : inputType)}"; + var finalOutputPath = $"{finalDir}\\final_output.{(inputType.Equals(nameof(FileType.Arm), StringComparison.OrdinalIgnoreCase) ? "json" : inputType)}"; virtualFileSystem.CreateDirectory(finalDir); ExecuteTranslationTask(previousOutputType, inputType, previousInputPath, finalOutputPath); + // Assert: Compare final output with original input to check no data loss + string originalInput = await virtualFileSystem.ReadAllTextAsync(originalInputPath).ConfigureAwait(false); + string finalOutput = await virtualFileSystem.ReadAllTextAsync(finalOutputPath).ConfigureAwait(false); + // Compare content only: the sample data carries the source file's line endings while writers use Environment.NewLine. + originalInput = originalInput.Replace("\r\n", "\n", StringComparison.Ordinal); + finalOutput = finalOutput.Replace("\r\n", "\n", StringComparison.Ordinal); + Assert.IsTrue(string.Equals(originalInput, finalOutput, StringComparison.Ordinal), $"Data mismatch after full conversion cycle for {inputType}.\nExpected:\n{originalInput}\nActual:\n{finalOutput}"); + return finalOutputPath; } - private void ExecuteTranslationTask(string inputType, string outputType, string inputFilePath, string outputFilePath) + public static IEnumerable GetFileTypes() { - var task = new AggregateConfig(virtualFileSystem, mockLogger.Object) + foreach (var type in Enum.GetValues()) { - InputDirectory = inputFilePath, - InputType = inputType, - OutputFile = outputFilePath, - OutputType = outputType, - BuildEngine = Mock.Of() - }; - bool result = task.Execute(); - Assert.IsTrue(result, $"Failed translation: {inputType} -> {outputType}"); - } - - private void AssertNoDataLoss(string originalFilePath, string finalFilePath, string inputType) - { - string originalInput = virtualFileSystem.ReadAllText(originalFilePath); - string finalOutput = virtualFileSystem.ReadAllText(finalFilePath); - Assert.IsTrue(string.Equals(originalInput, finalOutput, StringComparison.Ordinal), $"Data mismatch after full conversion cycle for {inputType}"); + yield return new object[] { type }; + } } - private static string GetSampleDataForType(string type) + public static IEnumerable GetFileTypeConversions() { - if (string.Equals(type, "json", StringComparison.Ordinal)) - { - return GetJsonSampleData(); - } - else if (string.Equals(type, "arm", StringComparison.Ordinal)) - { - return GetArmSampleData(); - } - else if (string.Equals(type, "yml", StringComparison.Ordinal)) - { - return GetYmlSampleData(); - } - else + // Enum aliases (Yaml/Yml, Arm/ArmParameter) share a value, so deduplicate by name. + var fileTypes = Enum.GetValues() + .Select(ft => ft.ToString().ToUpperInvariant()) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + // XML carries no type information, so it can only start a chain: typed JSON, YAML and ARM + // values that pass through XML would come back as strings. + var intermediateTypes = fileTypes + .Where(ft => !string.Equals(ft, "XML", StringComparison.Ordinal)) + .ToArray(); + + foreach (var initialFormat in fileTypes) { - throw new InvalidOperationException("Unknown type"); + var steps = intermediateTypes.Where(ft => !string.Equals(ft, initialFormat, StringComparison.Ordinal)).ToArray(); + foreach (var permutation in GetPermutations(steps, steps.Length)) + { + var displayName = $"{initialFormat.ToUpperInvariant()} -> {string.Join(" -> ", permutation.Select(p => p.ToUpperInvariant()))} -> {initialFormat.ToUpperInvariant()}"; + yield return new object[] { initialFormat, permutation.ToArray(), displayName }; + } } } - private static string GetJsonSampleData() + public static string GetTestDisplayName(MethodInfo methodInfo, object[] data) { - return """ -{ - "options": [ - { - "name": "Option 1", - "description": "First option", - "isTrue": true, - "number": 100, - "nested": [ - { - "name": "Nested option 1", - "description": "Nested first option", - "isTrue": true, - "number": 1001 - }, - { - "name": "Nested option 2", - "description": "Nested second option" - } - ] - } - ] -} -"""; + ArgumentNullException.ThrowIfNull(methodInfo); + ArgumentNullException.ThrowIfNull(data); + + // Get the custom display name from the third parameter. + return (string)data[2]; } - private static string GetArmSampleData() + private static IEnumerable> GetPermutations(IEnumerable list, int length) { - return """ -{ - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "options": { - "type": "object", - "value": { - "name": "Option 1", - "description": "First option", - "isTrue": true, - "number": 100, - "nested": [ - { - "name": "Nested option 1", - "description": "Nested first option", - "isTrue": true, - "number": 1002 - }, - { - "name": "Nested option 2", - "description": "Nested second option" - } - ] - } - } - } -} -"""; + if (length == 1) + { + return list.Select(t => new[] { t }); + } + + return GetPermutations(list, length - 1) + .SelectMany(t => list.Where(e => !t.Contains(e, StringComparer.Ordinal)), + (t1, t2) => t1.Concat([t2])); } - private static string GetYmlSampleData() + private void ExecuteTranslationTask(string inputType, string outputType, string inputFilePath, string outputFilePath) { - return @"options: -- name: Option 1 - description: First option - isTrue: true - number: 100 - nested: - - name: Nested option 1 - description: Nested first option - isTrue: true - number: 1003 - - name: Nested option 2 - description: Nested second option -"; + var task = new AggregateConfig(virtualFileSystem, mockLogger.Object) + { + InputDirectory = inputFilePath, + InputType = inputType, + OutputFile = outputFilePath, + OutputType = outputType, + BuildEngine = Mock.Of() + }; + bool result = task.Execute(); + Assert.IsTrue(result, $"Failed translation: {inputType} -> {outputType}"); } /// diff --git a/src/UnitTests/UnitTests.csproj b/src/UnitTests/UnitTests.csproj index 2c80bf7..ae73517 100644 --- a/src/UnitTests/UnitTests.csproj +++ b/src/UnitTests/UnitTests.csproj @@ -2,7 +2,7 @@ AggregateConfig.Tests.Unit - net9.0 + net10.0 false disable false diff --git a/src/UnitTests/VirtualFileSystem.cs b/src/UnitTests/VirtualFileSystem.cs index dd9199d..e5459ab 100644 --- a/src/UnitTests/VirtualFileSystem.cs +++ b/src/UnitTests/VirtualFileSystem.cs @@ -4,6 +4,7 @@ using System.IO; using System.Text; using System.Text.RegularExpressions; +using System.Threading.Tasks; namespace AggregateConfigBuildTask.Tests.Unit { @@ -15,7 +16,6 @@ internal sealed class VirtualFileSystem(bool isWindowsMode = true) : IFileSystem private RegexOptions RegexOptions => isWindowsMode ? RegexOptions.IgnoreCase : RegexOptions.None; private StringComparison StringComparison => isWindowsMode ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; - private string EnvironmentLineBreak => isWindowsMode ? "\r\n" : "\n"; /// public string[] GetFiles(string path, string searchPattern) @@ -42,29 +42,20 @@ public string[] GetFiles(string path, string searchPattern) } /// - public string[] ReadAllLines(string path) - { - path = NormalizePath(path); - - var content = ReadAllText(path); - return content.Split(EnvironmentLineBreak); - } - - /// - public string ReadAllText(string path) + public Task ReadAllTextAsync(string path) { path = NormalizePath(path); if (fileSystem.TryGetValue(path, out var content)) { - return content; + return Task.FromResult(content); } throw new FileNotFoundException($"The file '{path}' was not found in the virtual file system."); } /// - public void WriteAllText(string path, string text) + public Task WriteAllTextAsync(string path, string text) { path = NormalizePath(path); string directoryPath = Path.GetDirectoryName(path); @@ -81,6 +72,8 @@ public void WriteAllText(string path, string text) } fileSystem[path] = text; + + return Task.CompletedTask; } /// @@ -128,14 +121,14 @@ public void CreateDirectory(string directoryPath) /// public TextReader OpenText(string path) { - return new StringReader(ReadAllText(path)); + return new StringReader(ReadAllTextAsync(path).GetAwaiter().GetResult()); } /// public Stream OpenRead(string inputPath) { - var byteArray = Encoding.UTF8.GetBytes(ReadAllText(inputPath)); - return new MemoryStream(byteArray); + var text = ReadAllTextAsync(inputPath).GetAwaiter().GetResult(); + return new MemoryStream(Encoding.UTF8.GetBytes(text)); } /// diff --git a/test/IntegrationTests/EmbeddedResourceTests.cs b/test/IntegrationTests/EmbeddedResourceTests.cs index 8758831..b6e25d5 100644 --- a/test/IntegrationTests/EmbeddedResourceTests.cs +++ b/test/IntegrationTests/EmbeddedResourceTests.cs @@ -1,4 +1,5 @@ -using System.IO; +using System; +using System.IO; using System.Reflection; using System.Text.Json; @@ -30,5 +31,111 @@ public void ReadEmbeddedResource_DeserializesJsonSuccessfully(string resourceNam // Assert Assert.IsNotNull(outputData, "Deserialization of JSON failed."); } + + [TestMethod] + [DataRow("IntegrationTests.Aggregated.Basic.json", "JSON")] + [DataRow("IntegrationTests.Aggregated.Arm.parameters.json", "ARM")] + [DataRow("IntegrationTests.Aggregated.Output.yaml", "YAML")] + public void ReadItemBasedEmbeddedResource_ExistsAndIsValid(string resourceName, string formatType) + { + // Arrange + string content; + + // Act + using var stream = Assembly.GetAssembly(typeof(EmbeddedResourceTests)).GetManifestResourceStream(resourceName); + Assert.IsNotNull(stream, $"Item-based embedded resource '{resourceName}' not found. Available: {string.Join(", ", Assembly.GetAssembly(typeof(EmbeddedResourceTests)).GetManifestResourceNames())}"); + + using var reader = new StreamReader(stream); + content = reader.ReadToEnd(); + + // Assert + Assert.IsNotNull(content, "Content should not be null."); + Assert.IsTrue(content.Length > 0, "Content should not be empty."); + + // Additional validation based on format type + switch (formatType) + { + case "JSON": + var jsonData = JsonSerializer.Deserialize(content); + Assert.IsNotNull(jsonData, "JSON deserialization failed."); + break; + case "ARM": + var armData = JsonSerializer.Deserialize(content); + Assert.IsTrue(armData.TryGetProperty("parameters", out _), "ARM template should have 'parameters' property."); + break; + case "YAML": + Assert.IsTrue(content.Contains("servers", StringComparison.Ordinal) || content.Contains("databases", StringComparison.Ordinal), "YAML should contain expected keys."); + break; + } + } + + [TestMethod] + public void ItemBasedEmbeddedResource_BasicJson_ContainsSourceProperty() + { + // Arrange + string jsonContent; + + // Act + using var stream = Assembly.GetAssembly(typeof(EmbeddedResourceTests)).GetManifestResourceStream("IntegrationTests.Aggregated.Basic.json"); + Assert.IsNotNull(stream, "Item-based basic.json embedded resource not found."); + + using var reader = new StreamReader(stream); + jsonContent = reader.ReadToEnd(); + + // Assert + Assert.IsTrue(jsonContent.Contains("\"source\"", StringComparison.Ordinal), "JSON should contain 'source' property because AddSourceProperty was true."); + } + + [TestMethod] + public void ItemBasedEmbeddedResource_Yaml_ContainsSourceProperty() + { + // Arrange + string yamlContent; + + // Act + using var stream = Assembly.GetAssembly(typeof(EmbeddedResourceTests)).GetManifestResourceStream("IntegrationTests.Aggregated.Output.yaml"); + Assert.IsNotNull(stream, "Item-based output.yaml embedded resource not found."); + + using var reader = new StreamReader(stream); + yamlContent = reader.ReadToEnd(); + + // Assert + Assert.IsTrue(yamlContent.Contains("source:", StringComparison.Ordinal), "YAML should contain 'source:' property because AddSourceProperty was true."); + } + + [TestMethod] + public void CompareItemBasedVsLegacy_ProducesSameOutput() + { + // Arrange + string itemBasedJson; + string legacyJson; + + // Act - Read item-based output + using (var itemStream = Assembly.GetAssembly(typeof(EmbeddedResourceTests)).GetManifestResourceStream("IntegrationTests.Aggregated.Basic.json")) + { + Assert.IsNotNull(itemStream, "Item-based JSON not found."); + using var itemReader = new StreamReader(itemStream); + itemBasedJson = itemReader.ReadToEnd(); + } + + // Act - Read legacy output + using (var legacyStream = Assembly.GetAssembly(typeof(EmbeddedResourceTests)).GetManifestResourceStream("IntegrationTests.out.json.test.json")) + { + Assert.IsNotNull(legacyStream, "Legacy JSON not found."); + using var legacyReader = new StreamReader(legacyStream); + legacyJson = legacyReader.ReadToEnd(); + } + + // Assert - Both should be valid JSON with similar structure + var itemBasedData = JsonSerializer.Deserialize(itemBasedJson); + var legacyData = JsonSerializer.Deserialize(legacyJson); + + Assert.IsNotNull(itemBasedData); + Assert.IsNotNull(legacyData); + + // Both should have similar top-level properties (servers, databases, etc.) + Assert.IsTrue(itemBasedData.ValueKind == JsonValueKind.Object, "Item-based output should be a JSON object."); + Assert.IsTrue(legacyData.ValueKind == JsonValueKind.Object, "Legacy output should be a JSON object."); + } } } diff --git a/test/IntegrationTests/IntegrationTests.csproj b/test/IntegrationTests/IntegrationTests.csproj index 1bbeaf3..abcfe6d 100644 --- a/test/IntegrationTests/IntegrationTests.csproj +++ b/test/IntegrationTests/IntegrationTests.csproj @@ -2,13 +2,15 @@ AggregateConfig.Tests.Integration - net9.0 + net10.0 false disable false true true 0.0.1 + + $(DefaultItemExcludes);TestProjects/** @@ -19,13 +21,17 @@ - + + + + + $(MSBuildProjectDirectory)\output @@ -148,4 +154,40 @@ + + + + + + + IntegrationTests.Aggregated.Basic.json + + + + + + + IntegrationTests.Aggregated.Arm.parameters.json + + + + + + + IntegrationTests.Aggregated.Output.yaml + + + diff --git a/test/IntegrationTests/PackageStructureTests.cs b/test/IntegrationTests/PackageStructureTests.cs new file mode 100644 index 0000000..44a9f6e --- /dev/null +++ b/test/IntegrationTests/PackageStructureTests.cs @@ -0,0 +1,277 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Text.Json; +using System.Threading; + +namespace AggregateConfig.Tests.Integration +{ + /// + /// Integration tests that verify the package works correctly for both single-targeting (build/) + /// and multi-targeting (buildMultiTargeting/) project scenarios by building real projects. + /// + [TestClass] + public class PackageStructureTests + { + private static readonly string[] MultiTargetFrameworks = { "net8.0", "net9.0", "net10.0" }; + + private static readonly TimeSpan BuildTimeout = TimeSpan.FromMinutes(4); + + private static string TestProjectsPath => Path.Combine( + Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!, + "..", "..", "..", "TestProjects"); + + private static string SingleTargetProjectPath => Path.GetFullPath( + Path.Combine(TestProjectsPath, "SingleTarget", "SingleTargetProject.csproj")); + + private static string MultiTargetProjectPath => Path.GetFullPath( + Path.Combine(TestProjectsPath, "MultiTarget", "MultiTargetProject.csproj")); + + /// + /// The package version the test projects should reference. Set by the IntegrationTests project + /// when UseLocalPackageVersion is enabled so the freshly packed package is exercised; otherwise + /// empty and the test projects fall back to the version in their Directory.Build.props. + /// + private static string PackageVersion => Assembly.GetExecutingAssembly() + .GetCustomAttributes() + .FirstOrDefault(attribute => string.Equals(attribute.Key, "AggregateConfigBuildTaskVersion", StringComparison.Ordinal))? + .Value; + + /// + /// Verifies that a single-targeting project (using build/ folder) builds successfully from a clean state. + /// + [TestMethod] + public void SingleTargetProject_BuildsSuccessfully() + { + // Arrange + Assert.IsTrue(File.Exists(SingleTargetProjectPath), $"Test project not found at: {SingleTargetProjectPath}"); + DeleteBuildOutput(SingleTargetProjectPath); + + // Act + var (exitCode, output) = RunDotNetBuild(SingleTargetProjectPath); + + // Assert + Assert.AreEqual(0, exitCode, $"Build failed for single-target project:\n{output}"); + Assert.IsTrue(output.Contains("succeeded", StringComparison.OrdinalIgnoreCase), $"Build output should indicate success:\n{output}"); + } + + /// + /// Verifies that a multi-targeting project (using buildMultiTargeting/ folder) builds successfully + /// for all target frameworks from a clean state. + /// + [TestMethod] + public void MultiTargetProject_BuildsSuccessfully() + { + // Arrange + Assert.IsTrue(File.Exists(MultiTargetProjectPath), $"Test project not found at: {MultiTargetProjectPath}"); + DeleteBuildOutput(MultiTargetProjectPath); + + // Act + var (exitCode, output) = RunDotNetBuild(MultiTargetProjectPath); + + // Assert + Assert.AreEqual(0, exitCode, $"Build failed for multi-target project:\n{output}"); + Assert.IsTrue(output.Contains("succeeded", StringComparison.OrdinalIgnoreCase), $"Build output should indicate success:\n{output}"); + } + + /// + /// Verifies that the single-target project generates the expected aggregated config file. + /// + [TestMethod] + public void SingleTargetProject_GeneratesAggregatedConfig() + { + // Arrange + var projectDir = Path.GetDirectoryName(SingleTargetProjectPath)!; + var aggregatedPath = Path.Combine(projectDir, "obj", "Debug", "net10.0", "aggregated", "config.json"); + + // Act + var (exitCode, output) = RunDotNetBuild(SingleTargetProjectPath); + Assert.AreEqual(0, exitCode, $"Build failed:\n{output}"); + + // Assert + Assert.IsTrue(File.Exists(aggregatedPath), $"Aggregated config not found at: {aggregatedPath}"); + + var content = File.ReadAllText(aggregatedPath); + Assert.IsTrue(content.Contains("servers", StringComparison.Ordinal), "Aggregated config should contain 'servers' from servers.yml"); + Assert.IsTrue(content.Contains("databases", StringComparison.Ordinal), "Aggregated config should contain 'databases' from databases.yml"); + Assert.IsTrue(content.Contains("source", StringComparison.Ordinal), "Aggregated config should contain 'source' property (AddSourceProperty=true)"); + } + + /// + /// Verifies that the multi-target project generates aggregated config for each target framework. + /// + [TestMethod] + public void MultiTargetProject_GeneratesAggregatedConfigPerFramework() + { + // Arrange + var projectDir = Path.GetDirectoryName(MultiTargetProjectPath)!; + + // Act + var (exitCode, output) = RunDotNetBuild(MultiTargetProjectPath); + Assert.AreEqual(0, exitCode, $"Build failed:\n{output}"); + + // Assert + foreach (var tfm in MultiTargetFrameworks) + { + var aggregatedPath = Path.Combine(projectDir, "obj", "Debug", tfm, "aggregated", "config.json"); + Assert.IsTrue(File.Exists(aggregatedPath), $"Aggregated config not found for {tfm} at: {aggregatedPath}"); + + var content = File.ReadAllText(aggregatedPath); + Assert.IsTrue(content.Contains("servers", StringComparison.Ordinal), $"Aggregated config for {tfm} should contain 'servers'"); + Assert.IsTrue(content.Contains("databases", StringComparison.Ordinal), $"Aggregated config for {tfm} should contain 'databases'"); + } + } + + /// + /// Verifies that the legacy approach (direct task invocation) still works in single-target projects. + /// + [TestMethod] + public void SingleTargetProject_LegacyApproachWorks() + { + // Arrange + var projectDir = Path.GetDirectoryName(SingleTargetProjectPath)!; + var legacyPath = Path.Combine(projectDir, "obj", "Debug", "net10.0", "legacy", "output.json"); + + // Act + var (exitCode, output) = RunDotNetBuild(SingleTargetProjectPath); + Assert.AreEqual(0, exitCode, $"Build failed:\n{output}"); + + // Assert + Assert.IsTrue(File.Exists(legacyPath), $"Legacy output not found at: {legacyPath}"); + + var content = File.ReadAllText(legacyPath); + var json = JsonSerializer.Deserialize(content); + Assert.IsTrue(json.TryGetProperty("servers", out _) || json.TryGetProperty("databases", out _), + "Legacy output should contain config data"); + } + + /// + /// Verifies that the legacy approach works in multi-target projects. + /// + [TestMethod] + public void MultiTargetProject_LegacyApproachWorks() + { + // Arrange + var projectDir = Path.GetDirectoryName(MultiTargetProjectPath)!; + + // Act + var (exitCode, output) = RunDotNetBuild(MultiTargetProjectPath); + Assert.AreEqual(0, exitCode, $"Build failed:\n{output}"); + + // Assert + foreach (var tfm in MultiTargetFrameworks) + { + var legacyPath = Path.Combine(projectDir, "obj", "Debug", tfm, "legacy", "output.json"); + Assert.IsTrue(File.Exists(legacyPath), $"Legacy output not found for {tfm} at: {legacyPath}"); + } + } + + /// + /// Verifies that the AggregateConfig task runs and its output appears in the build output. + /// + [TestMethod] + public void SingleTargetProject_ShowsAggregateConfigOutput() + { + // Arrange - force a full rebuild so the task cannot be skipped as up to date + DeleteBuildOutput(SingleTargetProjectPath); + + // Act - build with normal verbosity to see task messages + var (exitCode, output) = RunDotNetBuild(SingleTargetProjectPath, "-v:n"); + + // Assert + Assert.AreEqual(0, exitCode, $"Build failed:\n{output}"); + Assert.IsTrue(output.Contains("AggregateConfig Version:", StringComparison.Ordinal), + $"Build output should contain the AggregateConfig task version message:\n{output}"); + } + + private static void DeleteBuildOutput(string projectPath) + { + var projectDir = Path.GetDirectoryName(projectPath)!; + foreach (var folder in new[] { "bin", "obj" }) + { + var path = Path.Combine(projectDir, folder); + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } + } + + private static ProcessStartInfo CreateBuildStartInfo(string projectPath, string additionalArgs) + { + var versionArg = string.IsNullOrEmpty(PackageVersion) + ? string.Empty + : $"-p:AggregateConfigBuildTaskVersion={PackageVersion}"; + + // Node reuse and the shared compiler server are disabled so the child build never tries to + // attach to the MSBuild nodes or VBCSCompiler owned by the outer test run, which can hang. + var startInfo = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"build \"{projectPath}\" --nologo -nodeReuse:false -p:UseSharedCompilation=false {versionArg} {additionalArgs}".Trim(), + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = Path.GetDirectoryName(projectPath) + }; + + // The test host inherits MSBuild environment from the outer build; clear it so the + // child dotnet process resolves its own SDK and does not pick up the parent's settings. + foreach (var variable in new[] { "MSBuildSDKsPath", "MSBuildExtensionsPath", "MSBUILD_EXE_PATH", "MSBuildLoadMicrosoftTargetsReadOnly", "DOTNET_HOST_PATH" }) + { + startInfo.Environment.Remove(variable); + } + + startInfo.Environment["MSBUILDDISABLENODEREUSE"] = "1"; + startInfo.Environment["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1"; + startInfo.Environment["DOTNET_NOLOGO"] = "1"; + startInfo.Environment["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1"; + + return startInfo; + } + + private static (int exitCode, string output) RunDotNetBuild(string projectPath, string additionalArgs = "") + { + var output = new StringBuilder(); + var outputLock = new Lock(); + + using var process = new Process { StartInfo = CreateBuildStartInfo(projectPath, additionalArgs) }; + process.OutputDataReceived += (_, args) => AppendLine(args.Data); + process.ErrorDataReceived += (_, args) => AppendLine(args.Data); + process.Start(); + + // Read both streams asynchronously so neither pipe can fill up and block the child. + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + if (!process.WaitForExit(BuildTimeout)) + { + process.Kill(entireProcessTree: true); + process.WaitForExit(); + throw new TimeoutException($"dotnet build of {projectPath} did not finish within {BuildTimeout}. Output so far:\n{output}"); + } + + // A second wait with no timeout flushes the asynchronous output handlers. + process.WaitForExit(); + + return (process.ExitCode, output.ToString()); + + void AppendLine(string line) + { + if (line is null) + { + return; + } + + lock (outputLock) + { + output.AppendLine(line); + } + } + } + } +} diff --git a/test/IntegrationTests/TestProjects/Directory.Build.props b/test/IntegrationTests/TestProjects/Directory.Build.props new file mode 100644 index 0000000..5260948 --- /dev/null +++ b/test/IntegrationTests/TestProjects/Directory.Build.props @@ -0,0 +1,8 @@ + + + + + + 1.0.8 + + diff --git a/test/IntegrationTests/TestProjects/Directory.Build.targets b/test/IntegrationTests/TestProjects/Directory.Build.targets new file mode 100644 index 0000000..7d7d332 --- /dev/null +++ b/test/IntegrationTests/TestProjects/Directory.Build.targets @@ -0,0 +1,3 @@ + + + diff --git a/test/IntegrationTests/TestProjects/MultiTarget/MultiTargetProject.csproj b/test/IntegrationTests/TestProjects/MultiTarget/MultiTargetProject.csproj new file mode 100644 index 0000000..284b11f --- /dev/null +++ b/test/IntegrationTests/TestProjects/MultiTarget/MultiTargetProject.csproj @@ -0,0 +1,42 @@ + + + + + net8.0;net9.0;net10.0 + Library + false + + false + + + + + + + + + + + + MultiTargetProject.Aggregated.config.json + + + + + + + + + + MultiTargetProject.Legacy.output.json + + + + + diff --git a/test/IntegrationTests/TestProjects/MultiTarget/configs/databases.yml b/test/IntegrationTests/TestProjects/MultiTarget/configs/databases.yml new file mode 100644 index 0000000..9e05abe --- /dev/null +++ b/test/IntegrationTests/TestProjects/MultiTarget/configs/databases.yml @@ -0,0 +1,7 @@ +databases: + - name: primary-db + host: db.example.com + port: 5432 + - name: replica-db + host: db-replica.example.com + port: 5432 diff --git a/test/IntegrationTests/TestProjects/MultiTarget/configs/servers.yml b/test/IntegrationTests/TestProjects/MultiTarget/configs/servers.yml new file mode 100644 index 0000000..f85dbb3 --- /dev/null +++ b/test/IntegrationTests/TestProjects/MultiTarget/configs/servers.yml @@ -0,0 +1,7 @@ +servers: + - name: web-server-1 + ip: 192.168.1.10 + port: 80 + - name: web-server-2 + ip: 192.168.1.11 + port: 80 diff --git a/test/IntegrationTests/TestProjects/SingleTarget/SingleTargetProject.csproj b/test/IntegrationTests/TestProjects/SingleTarget/SingleTargetProject.csproj new file mode 100644 index 0000000..90a4459 --- /dev/null +++ b/test/IntegrationTests/TestProjects/SingleTarget/SingleTargetProject.csproj @@ -0,0 +1,42 @@ + + + + + net10.0 + Library + false + + false + + + + + + + + + + + + SingleTargetProject.Aggregated.config.json + + + + + + + + + + SingleTargetProject.Legacy.output.json + + + + + diff --git a/test/IntegrationTests/TestProjects/SingleTarget/configs/databases.yml b/test/IntegrationTests/TestProjects/SingleTarget/configs/databases.yml new file mode 100644 index 0000000..9e05abe --- /dev/null +++ b/test/IntegrationTests/TestProjects/SingleTarget/configs/databases.yml @@ -0,0 +1,7 @@ +databases: + - name: primary-db + host: db.example.com + port: 5432 + - name: replica-db + host: db-replica.example.com + port: 5432 diff --git a/test/IntegrationTests/TestProjects/SingleTarget/configs/servers.yml b/test/IntegrationTests/TestProjects/SingleTarget/configs/servers.yml new file mode 100644 index 0000000..f85dbb3 --- /dev/null +++ b/test/IntegrationTests/TestProjects/SingleTarget/configs/servers.yml @@ -0,0 +1,7 @@ +servers: + - name: web-server-1 + ip: 192.168.1.10 + port: 80 + - name: web-server-2 + ip: 192.168.1.11 + port: 80 diff --git a/toc.yml b/toc.yml index 8233ee7..1130bd9 100644 --- a/toc.yml +++ b/toc.yml @@ -1,11 +1,13 @@ -### YamlMime:TableOfContent -items: -- name: Home - href: README.md -- name: Code Docs - href: docs/ -- name: GitHub 🔗 - href: https://github.com/richardsondev/AggregateConfigBuildTask -- name: NuGet 🔗 - href: https://www.nuget.org/packages/AggregateConfigBuildTask -memberLayout: memberpage +### YamlMime:TableOfContent +items: +- name: Home + href: README.md +- name: Migration Guide + href: MIGRATION_GUIDE.md +- name: Code Docs + href: docs/ +- name: GitHub 🔗 + href: https://github.com/richardsondev/AggregateConfigBuildTask +- name: NuGet 🔗 + href: https://www.nuget.org/packages/AggregateConfigBuildTask +memberLayout: memberpage