From 1ad2094d9affd25df037d8061789d1e83ba7cf44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Gether=20S=C3=B8rensen=20=28Delegate=29?= Date: Fri, 14 Nov 2025 14:43:28 +0100 Subject: [PATCH 1/7] Swapped to a new structure for app settings --- AssemblyAnalyzer/Reader/LocalReader.cs | 2 +- CLAUDE.md | 43 +- Dataverse/CustomApiWriter.cs | 2 +- Dataverse/DataverseWriter.cs | 2 +- Dataverse/DryRunDataverseWriter.cs | 2 +- .../Extensions/ServiceCollectionExtensions.cs | 2 +- Dataverse/PluginAssemblyWriter.cs | 2 +- Dataverse/PluginWriter.cs | 2 +- Dataverse/WebresourceWriter.cs | 2 +- Model/XrmSyncOptions.cs | 65 +- README.md | 26 +- SyncService/Difference/PrintService.cs | 2 +- SyncService/PluginSyncService.cs | 4 +- SyncService/WebresourceSyncService.cs | 4 +- Tests/Config/ConfigWriterTests.cs | 292 ++++---- Tests/Config/NamedConfigurationTests.cs | 134 ++-- Tests/Config/OptionsValidationTests.cs | 703 ++++++++---------- Tests/Logging/CIModeDemonstrationTests.cs | 14 +- Tests/Logging/SyncLoggerTests.cs | 8 +- Tests/Plugins/DifferenceUtilityTests.cs | 2 +- Tests/Plugins/PluginServiceTests.cs | 2 +- .../WebresourceSyncServiceTests.cs | 2 +- Tests/Webresources/WebresourceWriterTests.cs | 2 +- XrmSync/Commands/PluginAnalyzeCommand.cs | 61 +- XrmSync/Commands/PluginSyncCommand.cs | 72 +- XrmSync/Commands/WebresourceSyncCommand.cs | 72 +- XrmSync/Commands/XrmSyncCommandBase.cs | 18 +- XrmSync/Commands/XrmSyncRootCommand.cs | 164 ++-- XrmSync/Constants/CliOptions.cs | 6 +- .../Extensions/ServiceCollectionExtensions.cs | 20 +- XrmSync/Logging/SyncLogger.cs | 2 +- XrmSync/Options/ConfigValidationOutput.cs | 361 +++++---- XrmSync/Options/ConfigWriter.cs | 29 +- XrmSync/Options/IConfigurationBuilder.cs | 1 + .../Options/XrmSyncConfigurationBuilder.cs | 159 ++-- .../Options/XrmSyncConfigurationValidator.cs | 80 +- 36 files changed, 1255 insertions(+), 1109 deletions(-) diff --git a/AssemblyAnalyzer/Reader/LocalReader.cs b/AssemblyAnalyzer/Reader/LocalReader.cs index 3501c45..68b8a36 100644 --- a/AssemblyAnalyzer/Reader/LocalReader.cs +++ b/AssemblyAnalyzer/Reader/LocalReader.cs @@ -38,7 +38,7 @@ public async Task ReadAssemblyAsync(string assemblyDllPath, string } logger.LogDebug("Reading assembly from {AssemblyDllPath}", assemblyDllPath); - var assemblyInfo = await ReadAssemblyInternalAsync(assemblyDllPath, publisherPrefix, sharedOptions.Value.ConfigName, cancellationToken); + var assemblyInfo = await ReadAssemblyInternalAsync(assemblyDllPath, publisherPrefix, sharedOptions.Value.ProfileName, cancellationToken); // Cache the assembly info assemblyCache[assemblyDllPath] = assemblyInfo; diff --git a/CLAUDE.md b/CLAUDE.md index ffd710a..c80b33e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,11 +76,46 @@ The solution is organized into distinct layers with clear separation of concerns 5. Execute operations via `IWebresourceWriter` **Configuration System**: -- Hierarchical configuration under `XrmSync` section in `appsettings.json` -- Named configurations support (e.g., "default", "dev", "prod") -- CLI options override configuration file values +- Profile-based configuration under `XrmSync` section in `appsettings.json` +- Global settings (DryRun, LogLevel, CiMode) apply to all profiles +- Each profile contains a solution name and list of sync items (Plugin, PluginAnalysis, Webresource) +- Profile support (e.g., "default", "dev", "prod") via `--profile` flag +- CLI options override configuration file values for standalone execution - `--save-config` flag generates/updates configuration files from CLI arguments -- Root command can execute multiple sub-commands from a single configuration +- Root command can execute all sync items in a profile sequentially + +**Configuration Format**: +```json +{ + "XrmSync": { + "DryRun": false, + "LogLevel": "Information", + "CiMode": false, + "Profiles": [ + { + "Name": "dev", + "SolutionName": "MySolution", + "Sync": [ + { + "Type": "Plugin", + "AssemblyPath": "../path/to/plugin.dll" + }, + { + "Type": "Webresource", + "FolderPath": "../path/to/webresources" + }, + { + "Type": "PluginAnalysis", + "AssemblyPath": "../path/to/plugin.dll", + "PublisherPrefix": "new", + "PrettyPrint": true + } + ] + } + ] + } +} +``` **Command Architecture**: - All commands implement `IXrmSyncCommand` and extend `XrmSyncCommandBase` diff --git a/Dataverse/CustomApiWriter.cs b/Dataverse/CustomApiWriter.cs index f9549d4..7f48214 100644 --- a/Dataverse/CustomApiWriter.cs +++ b/Dataverse/CustomApiWriter.cs @@ -9,7 +9,7 @@ namespace XrmSync.Dataverse; -internal class CustomApiWriter(IDataverseWriter writer, IOptions configuration) : ICustomApiWriter +internal class CustomApiWriter(IDataverseWriter writer, IOptions configuration) : ICustomApiWriter { private Dictionary Parameters { get; } = new() { { "SolutionUniqueName", configuration.Value.SolutionName } diff --git a/Dataverse/DataverseWriter.cs b/Dataverse/DataverseWriter.cs index b45d931..0bd0605 100644 --- a/Dataverse/DataverseWriter.cs +++ b/Dataverse/DataverseWriter.cs @@ -15,7 +15,7 @@ internal sealed class DataverseWriter : IDataverseWriter private readonly ServiceClient serviceClient; private readonly ILogger logger; - public DataverseWriter(ServiceClient serviceClient, ILogger logger, IOptions configuration) + public DataverseWriter(ServiceClient serviceClient, ILogger logger, IOptions configuration) { if (configuration.Value.DryRun) { diff --git a/Dataverse/DryRunDataverseWriter.cs b/Dataverse/DryRunDataverseWriter.cs index b71a99f..0935fc4 100644 --- a/Dataverse/DryRunDataverseWriter.cs +++ b/Dataverse/DryRunDataverseWriter.cs @@ -14,7 +14,7 @@ internal class DryRunDataverseWriter : IDataverseWriter { private readonly ILogger logger; - public DryRunDataverseWriter(IOptions configuration, ILogger logger) + public DryRunDataverseWriter(IOptions configuration, ILogger logger) { if (!configuration.Value.DryRun) { diff --git a/Dataverse/Extensions/ServiceCollectionExtensions.cs b/Dataverse/Extensions/ServiceCollectionExtensions.cs index 0f4220f..8f4eef7 100644 --- a/Dataverse/Extensions/ServiceCollectionExtensions.cs +++ b/Dataverse/Extensions/ServiceCollectionExtensions.cs @@ -14,7 +14,7 @@ public static IServiceCollection AddDataverseConnection(this IServiceCollection services.AddSingleton(); services.AddSingleton((sp) => { - var options = sp.GetRequiredService>(); + var options = sp.GetRequiredService>(); return options.Value.DryRun ? ActivatorUtilities.CreateInstance(sp) diff --git a/Dataverse/PluginAssemblyWriter.cs b/Dataverse/PluginAssemblyWriter.cs index 47a5a21..530ed88 100644 --- a/Dataverse/PluginAssemblyWriter.cs +++ b/Dataverse/PluginAssemblyWriter.cs @@ -6,7 +6,7 @@ namespace XrmSync.Dataverse; -internal class PluginAssemblyWriter(IDataverseWriter writer, IOptions configuration) : IPluginAssemblyWriter +internal class PluginAssemblyWriter(IDataverseWriter writer, IOptions configuration) : IPluginAssemblyWriter { private Dictionary Parameters { get; } = new() { { "SolutionUniqueName", configuration.Value.SolutionName } diff --git a/Dataverse/PluginWriter.cs b/Dataverse/PluginWriter.cs index c084992..a5ebec1 100644 --- a/Dataverse/PluginWriter.cs +++ b/Dataverse/PluginWriter.cs @@ -10,7 +10,7 @@ namespace XrmSync.Dataverse; -internal class PluginWriter(IMessageReader messageReader, IDataverseWriter writer, IOptions configuration) : IPluginWriter +internal class PluginWriter(IMessageReader messageReader, IDataverseWriter writer, IOptions configuration) : IPluginWriter { private Dictionary Parameters { get; } = new() { { "SolutionUniqueName", configuration.Value.SolutionName } diff --git a/Dataverse/WebresourceWriter.cs b/Dataverse/WebresourceWriter.cs index c298791..d4b8048 100644 --- a/Dataverse/WebresourceWriter.cs +++ b/Dataverse/WebresourceWriter.cs @@ -8,7 +8,7 @@ namespace XrmSync.Dataverse; -internal class WebresourceWriter(IDataverseWriter writer, IOptions configuration) : IWebresourceWriter +internal class WebresourceWriter(IDataverseWriter writer, IOptions configuration) : IWebresourceWriter { private Dictionary Parameters { get; } = new() { { "SolutionUniqueName", configuration.Value.SolutionName } diff --git a/Model/XrmSyncOptions.cs b/Model/XrmSyncOptions.cs index 038e770..b71b1cd 100644 --- a/Model/XrmSyncOptions.cs +++ b/Model/XrmSyncOptions.cs @@ -1,50 +1,77 @@ using Microsoft.Extensions.Logging; using System.Runtime.CompilerServices; +using System.Text.Json.Serialization; [assembly: InternalsVisibleTo("Tests")] [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] namespace XrmSync.Model; -public record XrmSyncConfiguration(PluginOptions Plugin, WebresourceOptions Webresource, LoggerOptions Logger, ExecutionOptions Execution) +public record XrmSyncConfiguration(bool DryRun, LogLevel LogLevel, bool CiMode, List Profiles) { - public static XrmSyncConfiguration Empty => new (PluginOptions.Empty, WebresourceOptions.Empty, LoggerOptions.Empty, ExecutionOptions.Empty); + public static XrmSyncConfiguration Empty => new (false, LogLevel.Information, false, new List()); } -public record PluginOptions(PluginSyncOptions Sync, PluginAnalysisOptions Analysis) + +public record ProfileConfiguration(string Name, string SolutionName, List Sync) { - public static PluginOptions Empty => new(PluginSyncOptions.Empty, PluginAnalysisOptions.Empty); + public static ProfileConfiguration Empty => new (string.Empty, string.Empty, new List()); } -public record WebresourceOptions(WebresourceSyncOptions Sync) +[JsonPolymorphic(TypeDiscriminatorPropertyName = "Type")] +[JsonDerivedType(typeof(PluginSyncItem), typeDiscriminator: "Plugin")] +[JsonDerivedType(typeof(PluginAnalysisSyncItem), typeDiscriminator: "PluginAnalysis")] +[JsonDerivedType(typeof(WebresourceSyncItem), typeDiscriminator: "Webresource")] +public abstract record SyncItem { - public static WebresourceOptions Empty => new (WebresourceSyncOptions.Empty); + [JsonIgnore] + public abstract string SyncType { get; } } -public record PluginSyncOptions(string AssemblyPath, string SolutionName) +public record PluginSyncItem(string AssemblyPath) : SyncItem { - public static PluginSyncOptions Empty => new (string.Empty, string.Empty); + public static PluginSyncItem Empty => new (string.Empty); + + [JsonIgnore] + public override string SyncType => "Plugin"; } -public record PluginAnalysisOptions(string AssemblyPath, string PublisherPrefix, bool PrettyPrint) +public record PluginAnalysisSyncItem(string AssemblyPath, string PublisherPrefix, bool PrettyPrint) : SyncItem { - public static PluginAnalysisOptions Empty => new (string.Empty, "new", false); + public static PluginAnalysisSyncItem Empty => new (string.Empty, "new", false); + + [JsonIgnore] + public override string SyncType => "PluginAnalysis"; } -public record WebresourceSyncOptions(string FolderPath, string SolutionName) +public record WebresourceSyncItem(string FolderPath) : SyncItem { - public static WebresourceSyncOptions Empty => new (string.Empty, string.Empty); + public static WebresourceSyncItem Empty => new (string.Empty); + + [JsonIgnore] + public override string SyncType => "Webresource"; } -public record LoggerOptions(LogLevel LogLevel, bool CiMode) +public record SharedOptions(bool SaveConfig, string? SaveConfigTo, string ProfileName) { - public static LoggerOptions Empty => new (LogLevel.Information, false); + public static SharedOptions Empty => new(false, null, string.Empty); } -public record ExecutionOptions(bool DryRun) +// Command-specific options that can be populated from CLI or profile +public record PluginSyncCommandOptions(string AssemblyPath, string SolutionName) { - public static ExecutionOptions Empty => new (false); + public static PluginSyncCommandOptions Empty => new(string.Empty, string.Empty); } -public record SharedOptions(bool SaveConfig, string? SaveConfigTo, string ConfigName) +public record PluginAnalysisCommandOptions(string AssemblyPath, string PublisherPrefix, bool PrettyPrint) { - public static SharedOptions Empty => new(false, null, string.Empty); -} \ No newline at end of file + public static PluginAnalysisCommandOptions Empty => new(string.Empty, "new", false); +} + +public record WebresourceSyncCommandOptions(string FolderPath, string SolutionName) +{ + public static WebresourceSyncCommandOptions Empty => new(string.Empty, string.Empty); +} + +public record ExecutionModeOptions(bool DryRun) +{ + public static ExecutionModeOptions Empty => new(false); +} diff --git a/README.md b/README.md index 76bcef8..3e02e93 100644 --- a/README.md +++ b/README.md @@ -76,10 +76,10 @@ xrmsync webresources --folder "path/to/webresources" --solution-name "YourSoluti For repeated operations or complex configurations, you can read the configuration from the appsettings.json file: ```bash # Run all configured commands (plugins, webresources, analysis) -xrmsync --config default +xrmsync --profile default # Run a specific command with configuration -xrmsync plugins --config default +xrmsync plugins --profile default ``` You can also override specific options when using a configuration file: @@ -98,7 +98,7 @@ xrmsync plugins --dry-run --log-level Debug | `--dry-run` | | Perform a dry run without making changes | No | | `--log-level` | `-l` | Set the minimum log level (Trace, Debug, Information, Warning, Error, Critical) | No | | `--ci-mode` | `--ci` | Enable CI mode which prefixes all warnings and errors | No | -| `--config` | `-c` | Name of the configuration to load from appsettings.json | No | +| `--profile` | `-p`, `--profile-name` | Name of the profile to load from appsettings.json | No | | `--save-config` | `--sc` | Save current CLI options to appsettings.json | No | | `--save-config-to` | | If `--save-config` is specified, override the filename to save to | No | @@ -113,7 +113,7 @@ xrmsync plugins --dry-run --log-level Debug | `--dry-run` | | Perform a dry run without making changes | No | | `--log-level` | `-l` | Set the minimum log level (Trace, Debug, Information, Warning, Error, Critical) | No | | `--ci-mode` | `--ci` | Enable CI mode which prefixes all warnings and errors | No | -| `--config` | `-c` | Name of the configuration to load from appsettings.json | No | +| `--profile` | `-p`, `--profile-name` | Name of the profile to load from appsettings.json | No | | `--save-config` | `--sc` | Save current CLI options to appsettings.json | No | | `--save-config-to` | | If `--save-config` is specified, override the filename to save to | No | @@ -150,13 +150,13 @@ The webresource name in Dataverse is determined by the file path relative to the | Option | Short | Description | Required | |--------|-------|-------------|----------| -| `--config` | `-c` | Name of the configuration to validate | No (Default: "default") | +| `--profile` | `-p`, `--profile-name` | Name of the profile to validate | No (Default: "default") | **Config List Command** | Option | Short | Description | Required | |--------|-------|-------------|----------| -| No options | | Lists all configurations from appsettings.json | N/A | +| No options | | Lists all profiles from appsettings.json | N/A | ### Assembly Analysis @@ -180,7 +180,7 @@ You can validate your configuration files to ensure they are correctly set up: xrmsync config validate # Validate a specific named configuration -xrmsync config validate --config dev +xrmsync config validate --profile dev ``` The `config validate` command shows: @@ -307,10 +307,10 @@ XrmSync supports multiple named configurations within a single appsettings.json **Using named configurations:** ```bash # Use the 'default' configuration (or the only configuration if only one exists) -xrmsync --config default +xrmsync --profile default # Use a specific named configuration -xrmsync --config dev +xrmsync --profile dev # If --config is not specified, 'default' is used, or the single config if only one exists xrmsync @@ -353,7 +353,7 @@ When you call the root command with a configuration name, XrmSync will automatic ```bash # This will run plugin sync, plugin analysis, and webresource sync # for all that are configured in the 'default' configuration -xrmsync --config default +xrmsync --profile default ``` XrmSync will only execute sub-commands that have their required properties configured. For example: @@ -505,7 +505,7 @@ xrmsync webresources --folder "wwwroot" --solution-name "MyCustomSolution" --dry ```bash # Runs all configured sub-commands (plugin sync, analysis, webresource sync) # from the 'default' configuration -xrmsync --config default +xrmsync --profile default # Or simply (uses 'default' if it exists, or the only config if there's just one) xrmsync @@ -513,13 +513,13 @@ xrmsync #### Using a specific named configuration: ```bash -xrmsync --config prod +xrmsync --profile prod ``` #### Running a specific sub-command with configuration: ```bash # Uses the configuration but only runs the plugins command -xrmsync plugins --config dev +xrmsync plugins --profile dev ``` #### Configuration file with CLI overrides: diff --git a/SyncService/Difference/PrintService.cs b/SyncService/Difference/PrintService.cs index f4c2efa..a68410d 100644 --- a/SyncService/Difference/PrintService.cs +++ b/SyncService/Difference/PrintService.cs @@ -9,7 +9,7 @@ namespace XrmSync.SyncService.Difference; internal class PrintService( ILogger log, - IOptions configuration, + IOptions configuration, IDescription description, IDataverseReader dataverseReader ) : IPrintService diff --git a/SyncService/PluginSyncService.cs b/SyncService/PluginSyncService.cs index 8f04a96..ae17436 100644 --- a/SyncService/PluginSyncService.cs +++ b/SyncService/PluginSyncService.cs @@ -30,11 +30,11 @@ internal class PluginSyncService( IDifferenceCalculator differenceUtility, IDescription description, IPrintService printService, - IOptions configuration, ILogger log) : ISyncService + IOptions configuration, ILogger log) : ISyncService { private record SyncData(AssemblyInfo LocalAssembly, AssemblyInfo? CrmAssembly); - private readonly PluginSyncOptions options = configuration.Value; + private readonly PluginSyncCommandOptions options = configuration.Value; public async Task Sync(CancellationToken cancellationToken) { diff --git a/SyncService/WebresourceSyncService.cs b/SyncService/WebresourceSyncService.cs index a634489..807d3af 100644 --- a/SyncService/WebresourceSyncService.cs +++ b/SyncService/WebresourceSyncService.cs @@ -12,7 +12,7 @@ namespace XrmSync.SyncService; internal class WebresourceSyncService( - IOptions config, + IOptions config, ILogger log, ILocalReader localReader, ISolutionReader solutionReader, @@ -22,7 +22,7 @@ internal class WebresourceSyncService( IPrintService printService ) : ISyncService { - private readonly WebresourceSyncOptions options = config.Value; + private readonly WebresourceSyncCommandOptions options = config.Value; public Task Sync(CancellationToken cancellation) { diff --git a/Tests/Config/ConfigWriterTests.cs b/Tests/Config/ConfigWriterTests.cs index dc261ff..9648eb9 100644 --- a/Tests/Config/ConfigWriterTests.cs +++ b/Tests/Config/ConfigWriterTests.cs @@ -17,22 +17,25 @@ public ConfigWriterTests() } [Fact] - public async Task SaveConfig_CreatesNewFile_UsesDefaultConfigName() + public async Task SaveConfig_CreatesNewFile_WithProfiles() { // Arrange var tempFile = Path.GetTempFileName(); File.Delete(tempFile); // Ensure file doesn't exist - + XrmSyncConfiguration config = new ( - new ( - new ("test.dll", "TestSolution"), - new ("analysis.dll", "tst", true) - ), - new ( - new ("wwwroot", "WebSolution") - ), - new (LogLevel.Debug, false), - new (true) + DryRun: true, + LogLevel: LogLevel.Debug, + CiMode: false, + Profiles: new List + { + new("default", "TestSolution", new List + { + new PluginSyncItem("test.dll"), + new PluginAnalysisSyncItem("analysis.dll", "tst", true), + new WebresourceSyncItem("wwwroot") + }) + } ); var options = Options.Create(config); @@ -45,34 +48,38 @@ public async Task SaveConfig_CreatesNewFile_UsesDefaultConfigName() // Assert Assert.True(File.Exists(tempFile)); - + var content = await File.ReadAllTextAsync(tempFile); var json = JsonSerializer.Deserialize(content); - + var xrmSyncSection = json.GetProperty("XrmSync"); - var defaultConfigSection = xrmSyncSection.GetProperty("default"); - - var pluginSection = defaultConfigSection.GetProperty("Plugin"); - var syncSection = pluginSection.GetProperty("Sync"); - Assert.Equal("test.dll", syncSection.GetProperty("AssemblyPath").GetString()); - Assert.Equal("TestSolution", syncSection.GetProperty("SolutionName").GetString()); - - var analysisSection = pluginSection.GetProperty("Analysis"); - Assert.Equal("analysis.dll", analysisSection.GetProperty("AssemblyPath").GetString()); - Assert.Equal("tst", analysisSection.GetProperty("PublisherPrefix").GetString()); - Assert.True(analysisSection.GetProperty("PrettyPrint").GetBoolean()); - - var webSection = defaultConfigSection.GetProperty("Webresource"); - var webSyncSection = webSection.GetProperty("Sync"); - Assert.Equal("wwwroot", webSyncSection.GetProperty("FolderPath").GetString()); - Assert.Equal("WebSolution", webSyncSection.GetProperty("SolutionName").GetString()); - - var loggerSection = defaultConfigSection.GetProperty("Logger"); - Assert.Equal("Debug", loggerSection.GetProperty("LogLevel").GetString()); - Assert.False(loggerSection.GetProperty("CiMode").GetBoolean()); - - var executionSection = defaultConfigSection.GetProperty("Execution"); - Assert.True(executionSection.GetProperty("DryRun").GetBoolean()); + Assert.True(xrmSyncSection.GetProperty("DryRun").GetBoolean()); + Assert.Equal("Debug", xrmSyncSection.GetProperty("LogLevel").GetString()); + Assert.False(xrmSyncSection.GetProperty("CiMode").GetBoolean()); + + var profiles = xrmSyncSection.GetProperty("Profiles"); + Assert.Equal(1, profiles.GetArrayLength()); + + var profile = profiles[0]; + Assert.Equal("default", profile.GetProperty("Name").GetString()); + Assert.Equal("TestSolution", profile.GetProperty("SolutionName").GetString()); + + var syncItems = profile.GetProperty("Sync"); + Assert.Equal(3, syncItems.GetArrayLength()); + + var pluginSync = syncItems[0]; + Assert.Equal("Plugin", pluginSync.GetProperty("Type").GetString()); + Assert.Equal("test.dll", pluginSync.GetProperty("AssemblyPath").GetString()); + + var pluginAnalysis = syncItems[1]; + Assert.Equal("PluginAnalysis", pluginAnalysis.GetProperty("Type").GetString()); + Assert.Equal("analysis.dll", pluginAnalysis.GetProperty("AssemblyPath").GetString()); + Assert.Equal("tst", pluginAnalysis.GetProperty("PublisherPrefix").GetString()); + Assert.True(pluginAnalysis.GetProperty("PrettyPrint").GetBoolean()); + + var webresourceSync = syncItems[2]; + Assert.Equal("Webresource", webresourceSync.GetProperty("Type").GetString()); + Assert.Equal("wwwroot", webresourceSync.GetProperty("FolderPath").GetString()); } finally { @@ -82,22 +89,28 @@ public async Task SaveConfig_CreatesNewFile_UsesDefaultConfigName() } [Fact] - public async Task SaveConfig_CreatesNewFile_WithNamedStructure() + public async Task SaveConfig_CreatesNewFile_WithMultipleProfiles() { // Arrange var tempFile = Path.GetTempFileName(); File.Delete(tempFile); // Ensure file doesn't exist XrmSyncConfiguration config = new ( - new PluginOptions( - new ("dev.dll", "DevSolution"), - new ("dev-analysis.dll", "dev", false) - ), - new WebresourceOptions( - new ("dev-wwwroot", "DevWebSolution") - ), - new (LogLevel.Debug, false), - new (true) + DryRun: true, + LogLevel: LogLevel.Debug, + CiMode: false, + Profiles: new List + { + new("default", "DefaultSolution", new List + { + new PluginSyncItem("default.dll") + }), + new("dev", "DevSolution", new List + { + new PluginSyncItem("dev.dll"), + new PluginAnalysisSyncItem("dev-analysis.dll", "dev", false) + }) + } ); var options = Options.Create(config); @@ -106,20 +119,28 @@ public async Task SaveConfig_CreatesNewFile_WithNamedStructure() try { // Act - await configWriter.SaveConfig(tempFile, configName: "dev"); + await configWriter.SaveConfig(tempFile); // Assert Assert.True(File.Exists(tempFile)); - + var content = await File.ReadAllTextAsync(tempFile); var json = JsonSerializer.Deserialize(content); - + var xrmSyncSection = json.GetProperty("XrmSync"); - var devSection = xrmSyncSection.GetProperty("dev"); - var pluginSection = devSection.GetProperty("Plugin"); - var syncSection = pluginSection.GetProperty("Sync"); - Assert.Equal("dev.dll", syncSection.GetProperty("AssemblyPath").GetString()); - Assert.Equal("DevSolution", syncSection.GetProperty("SolutionName").GetString()); + var profiles = xrmSyncSection.GetProperty("Profiles"); + Assert.Equal(2, profiles.GetArrayLength()); + + var defaultProfile = profiles[0]; + Assert.Equal("default", defaultProfile.GetProperty("Name").GetString()); + Assert.Equal("DefaultSolution", defaultProfile.GetProperty("SolutionName").GetString()); + + var devProfile = profiles[1]; + Assert.Equal("dev", devProfile.GetProperty("Name").GetString()); + Assert.Equal("DevSolution", devProfile.GetProperty("SolutionName").GetString()); + + var devSyncItems = devProfile.GetProperty("Sync"); + Assert.Equal(2, devSyncItems.GetArrayLength()); } finally { @@ -143,15 +164,18 @@ public async Task SaveConfig_MergesWithExistingFile_PreservingOtherSections() await File.WriteAllTextAsync(tempFile, existingContent); XrmSyncConfiguration config = new ( - new ( - new ("test.dll", "TestSolution"), - new ("analysis.dll", "new", false) - ), - new ( - new ("wwwroot", "WebSolution") - ), - new (LogLevel.Debug, false), - new (true) + DryRun: true, + LogLevel: LogLevel.Debug, + CiMode: false, + Profiles: new List + { + new("default", "TestSolution", new List + { + new PluginSyncItem("test.dll"), + new PluginAnalysisSyncItem("analysis.dll", "new", false), + new WebresourceSyncItem("wwwroot") + }) + } ); var options = Options.Create(config); @@ -165,17 +189,19 @@ public async Task SaveConfig_MergesWithExistingFile_PreservingOtherSections() // Assert var content = await File.ReadAllTextAsync(tempFile); var json = JsonSerializer.Deserialize(content); - + // Check original section is preserved var otherSection = json.GetProperty("SomeOtherSection"); Assert.Equal("SomeValue", otherSection.GetProperty("SomeProperty").GetString()); - + // Check XrmSync section is added var xrmSyncSection = json.GetProperty("XrmSync"); - var defaultSection = xrmSyncSection.GetProperty("default"); - var pluginSection = defaultSection.GetProperty("Plugin"); - var syncSection = pluginSection.GetProperty("Sync"); - Assert.Equal("test.dll", syncSection.GetProperty("AssemblyPath").GetString()); + var profiles = xrmSyncSection.GetProperty("Profiles"); + Assert.Equal(1, profiles.GetArrayLength()); + + var profile = profiles[0]; + Assert.Equal("default", profile.GetProperty("Name").GetString()); + Assert.Equal("TestSolution", profile.GetProperty("SolutionName").GetString()); } finally { @@ -185,40 +211,49 @@ public async Task SaveConfig_MergesWithExistingFile_PreservingOtherSections() } [Fact] - public async Task SaveConfig_UpdatesExistingNamedConfig_WithoutAffectingOtherConfigs() + public async Task SaveConfig_UpdatesExistingConfig() { // Arrange var tempFile = Path.GetTempFileName(); var existingContent = """ { "XrmSync": { - "default": { - "Plugin": { - "Sync": { - "AssemblyPath": "default.dll", - "SolutionName": "DefaultSolution", - "LogLevel": "Information" - } - }, - "Execution": { - "DryRun": false + "DryRun": false, + "LogLevel": "Information", + "CiMode": false, + "Profiles": [ + { + "Name": "default", + "SolutionName": "OldSolution", + "Sync": [ + { + "Type": "Plugin", + "AssemblyPath": "old.dll" + } + ] } - } + ] } } """; await File.WriteAllTextAsync(tempFile, existingContent); XrmSyncConfiguration config = new ( - new ( - new ("dev.dll", "DevSolution"), - new ("dev-analysis.dll", "dev", true) - ), - new ( - new ("dev-wwwroot", "DevWebSolution") - ), - new (LogLevel.Debug, false), - new (true) + DryRun: true, + LogLevel: LogLevel.Debug, + CiMode: false, + Profiles: new List + { + new("default", "NewSolution", new List + { + new PluginSyncItem("new.dll"), + new PluginAnalysisSyncItem("new-analysis.dll", "new", true) + }), + new("dev", "DevSolution", new List + { + new PluginSyncItem("dev.dll") + }) + } ); var options = Options.Create(config); @@ -227,28 +262,32 @@ public async Task SaveConfig_UpdatesExistingNamedConfig_WithoutAffectingOtherCon try { // Act - await configWriter.SaveConfig(tempFile, configName: "dev"); + await configWriter.SaveConfig(tempFile); // Assert var content = await File.ReadAllTextAsync(tempFile); var json = JsonSerializer.Deserialize(content); - + var xrmSyncSection = json.GetProperty("XrmSync"); - - // Check default config is preserved - var defaultSection = xrmSyncSection.GetProperty("default"); - var defaultSync = defaultSection.GetProperty("Plugin").GetProperty("Sync"); - Assert.Equal("default.dll", defaultSync.GetProperty("AssemblyPath").GetString()); - var defaultExecution = defaultSection.GetProperty("Execution"); - Assert.False(defaultExecution.GetProperty("DryRun").GetBoolean()); - - // Check dev config is added - var devSection = xrmSyncSection.GetProperty("dev"); - var devSync = devSection.GetProperty("Plugin").GetProperty("Sync"); - Assert.Equal("dev.dll", devSync.GetProperty("AssemblyPath").GetString()); - Assert.Equal("DevSolution", devSync.GetProperty("SolutionName").GetString()); - var devExecution = devSection.GetProperty("Execution"); - Assert.True(devExecution.GetProperty("DryRun").GetBoolean()); + + // Check config was updated + Assert.True(xrmSyncSection.GetProperty("DryRun").GetBoolean()); + Assert.Equal("Debug", xrmSyncSection.GetProperty("LogLevel").GetString()); + + var profiles = xrmSyncSection.GetProperty("Profiles"); + Assert.Equal(2, profiles.GetArrayLength()); + + var defaultProfile = profiles[0]; + Assert.Equal("default", defaultProfile.GetProperty("Name").GetString()); + Assert.Equal("NewSolution", defaultProfile.GetProperty("SolutionName").GetString()); + + var defaultSync = defaultProfile.GetProperty("Sync"); + Assert.Equal(2, defaultSync.GetArrayLength()); + Assert.Equal("new.dll", defaultSync[0].GetProperty("AssemblyPath").GetString()); + + var devProfile = profiles[1]; + Assert.Equal("dev", devProfile.GetProperty("Name").GetString()); + Assert.Equal("DevSolution", devProfile.GetProperty("SolutionName").GetString()); } finally { @@ -263,21 +302,24 @@ public async Task SaveConfig_UsesDefaultFileName_WhenFilePathIsNull() // Arrange var currentDir = Directory.GetCurrentDirectory(); var defaultFile = Path.Combine(currentDir, "appsettings.json"); - + // Clean up if file exists if (File.Exists(defaultFile)) File.Delete(defaultFile); XrmSyncConfiguration config = new ( - new ( - new ("test.dll", "TestSolution"), - new ("analysis.dll", "new", false) - ), - new ( - new ("wwwroot", "WebSolution") - ), - new (LogLevel.Debug, false), - new ExecutionOptions(false) + DryRun: false, + LogLevel: LogLevel.Debug, + CiMode: false, + Profiles: new List + { + new("default", "TestSolution", new List + { + new PluginSyncItem("test.dll"), + new PluginAnalysisSyncItem("analysis.dll", "new", false), + new WebresourceSyncItem("wwwroot") + }) + } ); var options = Options.Create(config); @@ -290,16 +332,16 @@ public async Task SaveConfig_UsesDefaultFileName_WhenFilePathIsNull() // Assert Assert.True(File.Exists(defaultFile)); - + var content = await File.ReadAllTextAsync(defaultFile); var json = JsonSerializer.Deserialize(content); - + var xrmSyncSection = json.GetProperty("XrmSync"); - var defaultSection = xrmSyncSection.GetProperty("default"); - var pluginSection = defaultSection.GetProperty("Plugin"); - var syncSection = pluginSection.GetProperty("Sync"); - Assert.Equal("test.dll", syncSection.GetProperty("AssemblyPath").GetString()); - Assert.Equal("TestSolution", syncSection.GetProperty("SolutionName").GetString()); + var profiles = xrmSyncSection.GetProperty("Profiles"); + var profile = profiles[0]; + var syncItems = profile.GetProperty("Sync"); + Assert.Equal("test.dll", syncItems[0].GetProperty("AssemblyPath").GetString()); + Assert.Equal("TestSolution", profile.GetProperty("SolutionName").GetString()); } finally { @@ -307,4 +349,4 @@ public async Task SaveConfig_UsesDefaultFileName_WhenFilePathIsNull() File.Delete(defaultFile); } } -} \ No newline at end of file +} diff --git a/Tests/Config/NamedConfigurationTests.cs b/Tests/Config/NamedConfigurationTests.cs index 6cd10f9..bdc44ea 100644 --- a/Tests/Config/NamedConfigurationTests.cs +++ b/Tests/Config/NamedConfigurationTests.cs @@ -16,37 +16,52 @@ public void ResolveConfigurationName_WithSpecificName_ReturnsRequestedName() const string configJson = """ { "XrmSync": { - "default": { - "Plugin": { - "Sync": { - "AssemblyPath": "default.dll" - } + "DryRun": false, + "LogLevel": "Information", + "CiMode": false, + "Profiles": [ + { + "Name": "default", + "SolutionName": "DefaultSolution", + "Sync": [ + { + "Type": "Plugin", + "AssemblyPath": "default.dll" + } + ] + }, + { + "Name": "dev", + "SolutionName": "DevSolution", + "Sync": [ + { + "Type": "Plugin", + "AssemblyPath": "dev.dll" + } + ] } - }, - "dev": { - "Plugin": { - "Sync": { - "AssemblyPath": "dev.dll" - } - } - } + ] } } """; - + var tempFile = Path.GetTempFileName(); File.WriteAllText(tempFile, configJson); - + try { var configReader = new TestConfigReader(tempFile); - var builder = new XrmSyncConfigurationBuilder(configReader.GetConfiguration(), Options.Create(SharedOptions.Empty with { ConfigName = "dev" })); + var builder = new XrmSyncConfigurationBuilder(configReader.GetConfiguration(), Options.Create(SharedOptions.Empty with { ProfileName = "dev" })); // Act - var configuration = builder.Build(); - + var profile = builder.GetProfile("dev"); + // Assert - Assert.Equal("dev.dll", configuration.Plugin.Sync.AssemblyPath); + Assert.NotNull(profile); + Assert.Equal("dev", profile.Name); + Assert.Single(profile.Sync); + var pluginSync = Assert.IsType(profile.Sync[0]); + Assert.Equal("dev.dll", pluginSync.AssemblyPath); } finally { @@ -61,37 +76,52 @@ public void ResolveConfigurationName_WithNoSpecificName_ReturnsDefault() const string configJson = """ { "XrmSync": { - "default": { - "Plugin": { - "Sync": { - "AssemblyPath": "default.dll" - } - } - }, - "dev": { - "Plugin": { - "Sync": { - "AssemblyPath": "dev.dll" - } + "DryRun": false, + "LogLevel": "Information", + "CiMode": false, + "Profiles": [ + { + "Name": "default", + "SolutionName": "DefaultSolution", + "Sync": [ + { + "Type": "Plugin", + "AssemblyPath": "default.dll" + } + ] + }, + { + "Name": "dev", + "SolutionName": "DevSolution", + "Sync": [ + { + "Type": "Plugin", + "AssemblyPath": "dev.dll" + } + ] } - } + ] } } """; - + var tempFile = Path.GetTempFileName(); File.WriteAllText(tempFile, configJson); - + try { var configReader = new TestConfigReader(tempFile); var builder = new XrmSyncConfigurationBuilder(configReader.GetConfiguration(), Options.Create(SharedOptions.Empty)); // Act - var result = builder.Build(); + var profile = builder.GetProfile(null); // Assert - Assert.Equal("default.dll", result.Plugin.Sync.AssemblyPath); + Assert.NotNull(profile); + Assert.Equal("default", profile.Name); + Assert.Single(profile.Sync); + var pluginSync = Assert.IsType(profile.Sync[0]); + Assert.Equal("default.dll", pluginSync.AssemblyPath); } finally { @@ -106,30 +136,42 @@ public void ResolveConfigurationName_WithSingleConfig_ReturnsThatConfig() const string configJson = """ { "XrmSync": { - "myconfig": { - "Plugin": { - "Sync": { - "AssemblyPath": "myconfig.dll" - } + "DryRun": false, + "LogLevel": "Information", + "CiMode": false, + "Profiles": [ + { + "Name": "myconfig", + "SolutionName": "MySolution", + "Sync": [ + { + "Type": "Plugin", + "AssemblyPath": "myconfig.dll" + } + ] } - } + ] } } """; - + var tempFile = Path.GetTempFileName(); File.WriteAllText(tempFile, configJson); - + try { var configReader = new TestConfigReader(tempFile); var builder = new XrmSyncConfigurationBuilder(configReader.GetConfiguration(), Options.Create(SharedOptions.Empty)); // Act - var result = builder.Build(); + var profile = builder.GetProfile(null); // Assert - Assert.Equal("myconfig.dll", result.Plugin.Sync.AssemblyPath); + Assert.NotNull(profile); + Assert.Equal("myconfig", profile.Name); + Assert.Single(profile.Sync); + var pluginSync = Assert.IsType(profile.Sync[0]); + Assert.Equal("myconfig.dll", pluginSync.AssemblyPath); } finally { diff --git a/Tests/Config/OptionsValidationTests.cs b/Tests/Config/OptionsValidationTests.cs index 86d3674..1ff0e98 100644 --- a/Tests/Config/OptionsValidationTests.cs +++ b/Tests/Config/OptionsValidationTests.cs @@ -7,589 +7,466 @@ namespace Tests.Config; public class OptionsValidationTests { + private static SharedOptions CreateSharedOptions(string profileName = "default") => + new SharedOptions(false, null, profileName); + [Fact] - public void SyncOptionsValidator_ValidOptions_PassesValidation() + public void PluginSyncValidator_ValidOptions_PassesValidation() { - // Arrange - var options = new PluginSyncOptions( - AssemblyPath: "test.dll", - SolutionName: "TestSolution" - ); - - // Create a test DLL file + // Arrange - Create a test DLL file var tempFile = Path.GetTempFileName(); File.Move(tempFile, Path.ChangeExtension(tempFile, ".dll")); var dllPath = Path.ChangeExtension(tempFile, ".dll"); File.WriteAllText(dllPath, "test content"); - options = options with { AssemblyPath = dllPath }; - try { + var config = new XrmSyncConfiguration( + DryRun: false, + LogLevel: LogLevel.Information, + CiMode: false, + Profiles: new List + { + new("default", "TestSolution", new List + { + new PluginSyncItem(dllPath) + }) + } + ); + // Act & Assert - var validator = new XrmSyncConfigurationValidator(Options.Create(XrmSyncConfiguration.Empty with { Plugin = PluginOptions.Empty with { Sync = options } })); + var validator = new XrmSyncConfigurationValidator( + Options.Create(config), + Options.Create(CreateSharedOptions())); validator.Validate(ConfigurationScope.PluginSync); // Should not throw } finally { - // Cleanup if (File.Exists(dllPath)) File.Delete(dllPath); } } [Fact] - public void SyncOptionsValidator_EmptyAssemblyPath_ThrowsValidationException() + public void PluginSyncValidator_EmptyAssemblyPath_ThrowsValidationException() { // Arrange - var options = new PluginSyncOptions( - AssemblyPath: "", - SolutionName: "TestSolution" + var config = new XrmSyncConfiguration( + DryRun: false, + LogLevel: LogLevel.Information, + CiMode: false, + Profiles: new List + { + new("default", "TestSolution", new List + { + new PluginSyncItem("") + }) + } ); // Act & Assert - var validator = new XrmSyncConfigurationValidator(Options.Create(XrmSyncConfiguration.Empty with { Plugin = PluginOptions.Empty with { Sync = options } })); - var exception = Assert.Throws(() => validator.Validate(ConfigurationScope.PluginSync)); + var validator = new XrmSyncConfigurationValidator( + Options.Create(config), + Options.Create(CreateSharedOptions())); + var exception = Assert.Throws( + () => validator.Validate(ConfigurationScope.PluginSync)); Assert.Contains("Assembly path is required", exception.Message); } [Fact] - public void SyncOptionsValidator_EmptySolutionName_ThrowsValidationException() - { - // Arrange - var options = new PluginSyncOptions( - AssemblyPath: "test.dll", - SolutionName: "" - ); - - // Act & Assert - var validator = new XrmSyncConfigurationValidator(Options.Create(XrmSyncConfiguration.Empty with { Plugin = PluginOptions.Empty with { Sync = options } })); - var exception = Assert.Throws(() => validator.Validate(ConfigurationScope.PluginSync)); - Assert.Contains("Solution name is required", exception.Message); - } - - [Fact] - public void SyncOptionsValidator_NonExistentAssemblyFile_ThrowsValidationException() + public void PluginSyncValidator_NonExistentAssemblyFile_ThrowsValidationException() { // Arrange - var options = new PluginSyncOptions( - AssemblyPath: "nonexistent.dll", - SolutionName: "TestSolution" + var config = new XrmSyncConfiguration( + DryRun: false, + LogLevel: LogLevel.Information, + CiMode: false, + Profiles: new List + { + new("default", "TestSolution", new List + { + new PluginSyncItem("nonexistent.dll") + }) + } ); // Act & Assert - var validator = new XrmSyncConfigurationValidator(Options.Create(XrmSyncConfiguration.Empty with { Plugin = PluginOptions.Empty with { Sync = options } })); - var exception = Assert.Throws(() => validator.Validate(ConfigurationScope.PluginSync)); + var validator = new XrmSyncConfigurationValidator( + Options.Create(config), + Options.Create(CreateSharedOptions())); + var exception = Assert.Throws( + () => validator.Validate(ConfigurationScope.PluginSync)); Assert.Contains("Assembly file does not exist", exception.Message); } [Fact] - public void SyncOptionsValidator_WrongFileExtension_ThrowsValidationException() + public void PluginSyncValidator_WrongFileExtension_ThrowsValidationException() { // Arrange - var options = new PluginSyncOptions( - AssemblyPath: "testhost.exe", - SolutionName: "TestSolution" + var config = new XrmSyncConfiguration( + DryRun: false, + LogLevel: LogLevel.Information, + CiMode: false, + Profiles: new List + { + new("default", "TestSolution", new List + { + new PluginSyncItem("testhost.exe") + }) + } ); // Act & Assert - var validator = new XrmSyncConfigurationValidator(Options.Create(XrmSyncConfiguration.Empty with { Plugin = PluginOptions.Empty with { Sync = options } })); - var exception = Assert.Throws(() => validator.Validate(ConfigurationScope.PluginSync)); + var validator = new XrmSyncConfigurationValidator( + Options.Create(config), + Options.Create(CreateSharedOptions())); + var exception = Assert.Throws( + () => validator.Validate(ConfigurationScope.PluginSync)); Assert.Contains("Assembly file must have a .dll extension", exception.Message); } [Fact] - public void SyncOptionsValidator_SolutionNameTooLong_ThrowsValidationException() + public void ProfileValidator_SolutionNameTooLong_ThrowsValidationException() { // Arrange - var options = new PluginSyncOptions( - AssemblyPath: "test.dll", - SolutionName: new string('a', 66) // 66 characters - ); - - // Act & Assert - var validator = new XrmSyncConfigurationValidator(Options.Create(XrmSyncConfiguration.Empty with { Plugin = PluginOptions.Empty with { Sync = options } })); - var exception = Assert.Throws(() => validator.Validate(ConfigurationScope.PluginSync)); - Assert.Contains("Solution name cannot exceed 65 characters", exception.Message); - } - - [Fact] - public void AnalysisOptionsValidator_ValidOptions_PassesValidation() - { - // Arrange - - // Create a test DLL file var tempFile = Path.GetTempFileName(); File.Move(tempFile, Path.ChangeExtension(tempFile, ".dll")); var dllPath = Path.ChangeExtension(tempFile, ".dll"); File.WriteAllText(dllPath, "test content"); - var options = new PluginAnalysisOptions( - AssemblyPath: dllPath, - PublisherPrefix: "contoso", - PrettyPrint: true - ); - try { + var config = new XrmSyncConfiguration( + DryRun: false, + LogLevel: LogLevel.Information, + CiMode: false, + Profiles: new List + { + new("default", new string('a', 66), new List + { + new PluginSyncItem(dllPath) + }) + } + ); + // Act & Assert - var validator = new XrmSyncConfigurationValidator(Options.Create(XrmSyncConfiguration.Empty with { Plugin = PluginOptions.Empty with { Analysis = options } })); - validator.Validate(ConfigurationScope.PluginAnalysis); // Should not throw + var validator = new XrmSyncConfigurationValidator( + Options.Create(config), + Options.Create(CreateSharedOptions())); + var exception = Assert.Throws( + () => validator.Validate(ConfigurationScope.PluginSync)); + Assert.Contains("Solution name cannot exceed 65 characters", exception.Message); } finally { - // Cleanup if (File.Exists(dllPath)) File.Delete(dllPath); } } [Fact] - public void AnalysisOptionsValidator_EmptyAssemblyPath_ThrowsValidationException() - { - // Arrange - var options = new PluginAnalysisOptions( - AssemblyPath: "", - PublisherPrefix: "contoso", - PrettyPrint: true - ); - - // Act & Assert - var validator = new XrmSyncConfigurationValidator(Options.Create(XrmSyncConfiguration.Empty with { Plugin = PluginOptions.Empty with { Analysis = options } })); - var exception = Assert.Throws(() => validator.Validate(ConfigurationScope.PluginAnalysis)); - Assert.Contains("Assembly path is required", exception.Message); - } - - [Fact] - public void AnalysisOptionsValidator_EmptyPublisherPrefix_ThrowsValidationException() - { - // Arrange - var options = new PluginAnalysisOptions( - AssemblyPath: "test.dll", - PublisherPrefix: "", - PrettyPrint: true - ); - - // Act & Assert - var validator = new XrmSyncConfigurationValidator(Options.Create(XrmSyncConfiguration.Empty with { Plugin = PluginOptions.Empty with { Analysis = options } })); - var exception = Assert.Throws(() => validator.Validate(ConfigurationScope.PluginAnalysis)); - Assert.Contains("Publisher prefix is required", exception.Message); - } - - [Fact] - public void AnalysisOptionsValidator_InvalidPublisherPrefixTooShort_ThrowsValidationException() + public void PluginAnalysisValidator_ValidOptions_PassesValidation() { - // Arrange - var options = new PluginAnalysisOptions( - AssemblyPath: "test.dll", - PublisherPrefix: "a", // Only 1 character - PrettyPrint: true - ); - - // Act & Assert - var validator = new XrmSyncConfigurationValidator(Options.Create(XrmSyncConfiguration.Empty with { Plugin = PluginOptions.Empty with { Analysis = options } })); - var exception = Assert.Throws(() => validator.Validate(ConfigurationScope.PluginAnalysis)); - Assert.Contains("Publisher prefix must be between 2 and 8 characters", exception.Message); - } - - [Fact] - public void AnalysisOptionsValidator_InvalidPublisherPrefixTooLong_ThrowsValidationException() - { - // Arrange - var options = new PluginAnalysisOptions( - AssemblyPath: "test.dll", - PublisherPrefix: "toolongprefix", // More than 8 characters - PrettyPrint: true - ); - - // Act & Assert - var validator = new XrmSyncConfigurationValidator(Options.Create(XrmSyncConfiguration.Empty with { Plugin = PluginOptions.Empty with { Analysis = options } })); - var exception = Assert.Throws(() => validator.Validate(ConfigurationScope.PluginAnalysis)); - Assert.Contains("Publisher prefix must be between 2 and 8 characters", exception.Message); - } - - [Fact] - public void AnalysisOptionsValidator_InvalidPublisherPrefixFormat_ThrowsValidationException() - { - // Arrange - var options = new PluginAnalysisOptions( - AssemblyPath: "test.dll", - PublisherPrefix: "Con2so", // Contains uppercase and numbers not at end - PrettyPrint: true - ); - - // Act & Assert - var validator = new XrmSyncConfigurationValidator(Options.Create(XrmSyncConfiguration.Empty with { Plugin = PluginOptions.Empty with { Analysis = options } })); - var exception = Assert.Throws(() => validator.Validate(ConfigurationScope.PluginAnalysis)); - Assert.Contains("Publisher prefix must start with a lowercase letter and contain only lowercase letters and numbers", exception.Message); - } - - [Theory] - [InlineData("contoso")] - [InlineData("ms")] - [InlineData("contoso1")] - [InlineData("abc123")] - public void AnalysisOptionsValidator_ValidPublisherPrefixes_PassValidation(string publisherPrefix) - { - // Arrange - - // Create a test DLL file + // Arrange - Create a test DLL file var tempFile = Path.GetTempFileName(); File.Move(tempFile, Path.ChangeExtension(tempFile, ".dll")); var dllPath = Path.ChangeExtension(tempFile, ".dll"); File.WriteAllText(dllPath, "test content"); - var options = new PluginAnalysisOptions( - AssemblyPath: dllPath, - PublisherPrefix: publisherPrefix, - PrettyPrint: true - ); - try { + var config = new XrmSyncConfiguration( + DryRun: false, + LogLevel: LogLevel.Information, + CiMode: false, + Profiles: new List + { + new("default", "TestSolution", new List + { + new PluginAnalysisSyncItem(dllPath, "contoso", true) + }) + } + ); + // Act & Assert - var validator = new XrmSyncConfigurationValidator(Options.Create(XrmSyncConfiguration.Empty with { Plugin = PluginOptions.Empty with { Analysis = options } })); + var validator = new XrmSyncConfigurationValidator( + Options.Create(config), + Options.Create(CreateSharedOptions())); validator.Validate(ConfigurationScope.PluginAnalysis); // Should not throw } finally { - // Cleanup if (File.Exists(dllPath)) File.Delete(dllPath); } } - [Theory] - [InlineData("Contoso")] // Starts with uppercase - [InlineData("1contoso")] // Starts with number - [InlineData("con-toso")] // Contains hyphen - [InlineData("con_toso")] // Contains underscore - [InlineData("con.toso")] // Contains dot - public void AnalysisOptionsValidator_InvalidPublisherPrefixFormats_ThrowValidationException(string publisherPrefix) - { - // Arrange - var options = new PluginAnalysisOptions( - AssemblyPath: "test.dll", - PublisherPrefix: publisherPrefix, - PrettyPrint: true - ); - - // Act & Assert - var validator = new XrmSyncConfigurationValidator(Options.Create(XrmSyncConfiguration.Empty with { Plugin = PluginOptions.Empty with { Analysis = options } })); - var exception = Assert.Throws(() => validator.Validate(ConfigurationScope.PluginAnalysis)); - Assert.Contains("Publisher prefix must start with a lowercase letter and contain only lowercase letters and numbers", exception.Message); - } - - // Webresource validation tests start here [Fact] - public void WebresourceSyncOptionsValidator_ValidOptions_PassesValidation() + public void PluginAnalysisValidator_EmptyPublisherPrefix_ThrowsValidationException() { // Arrange - // Create a test directory - var tempDir = Path.GetTempFileName(); - File.Delete(tempDir); - Directory.CreateDirectory(tempDir); - - var options = new WebresourceSyncOptions( - FolderPath: tempDir, - SolutionName: "TestSolution" - ); + var tempFile = Path.GetTempFileName(); + File.Move(tempFile, Path.ChangeExtension(tempFile, ".dll")); + var dllPath = Path.ChangeExtension(tempFile, ".dll"); + File.WriteAllText(dllPath, "test content"); try { + var config = new XrmSyncConfiguration( + DryRun: false, + LogLevel: LogLevel.Information, + CiMode: false, + Profiles: new List + { + new("default", "TestSolution", new List + { + new PluginAnalysisSyncItem(dllPath, "", true) + }) + } + ); + // Act & Assert - var validator = new XrmSyncConfigurationValidator(Options.Create(XrmSyncConfiguration.Empty with { Webresource = WebresourceOptions.Empty with { Sync = options } })); - validator.Validate(ConfigurationScope.WebresourceSync); // Should not throw + var validator = new XrmSyncConfigurationValidator( + Options.Create(config), + Options.Create(CreateSharedOptions())); + var exception = Assert.Throws( + () => validator.Validate(ConfigurationScope.PluginAnalysis)); + Assert.Contains("Publisher prefix is required", exception.Message); } finally { - // Cleanup - if (Directory.Exists(tempDir)) - Directory.Delete(tempDir, true); + if (File.Exists(dllPath)) + File.Delete(dllPath); } } [Fact] - public void WebresourceSyncOptionsValidator_EmptyFolderPath_ThrowsValidationException() - { - // Arrange - var options = new WebresourceSyncOptions( - FolderPath: "", - SolutionName: "TestSolution" - ); - - // Act & Assert - var validator = new XrmSyncConfigurationValidator(Options.Create(XrmSyncConfiguration.Empty with { Webresource = WebresourceOptions.Empty with { Sync = options } })); - var exception = Assert.Throws(() => validator.Validate(ConfigurationScope.WebresourceSync)); - Assert.Contains("Webresource root path is required", exception.Message); - } - - [Fact] - public void WebresourceSyncOptionsValidator_NullFolderPath_ThrowsValidationException() + public void PluginAnalysisValidator_InvalidPublisherPrefixTooShort_ThrowsValidationException() { // Arrange - var options = new WebresourceSyncOptions( - FolderPath: null!, - SolutionName: "TestSolution" - ); - - // Act & Assert - var validator = new XrmSyncConfigurationValidator(Options.Create(XrmSyncConfiguration.Empty with { Webresource = WebresourceOptions.Empty with { Sync = options } })); - var exception = Assert.Throws(() => validator.Validate(ConfigurationScope.WebresourceSync)); - Assert.Contains("Webresource root path is required", exception.Message); - } - - [Fact] - public void WebresourceSyncOptionsValidator_WhitespaceFolderPath_ThrowsValidationException() - { - // Arrange - var options = new WebresourceSyncOptions( - FolderPath: " ", - SolutionName: "TestSolution" - ); - - // Act & Assert - var validator = new XrmSyncConfigurationValidator(Options.Create(XrmSyncConfiguration.Empty with { Webresource = WebresourceOptions.Empty with { Sync = options } })); - var exception = Assert.Throws(() => validator.Validate(ConfigurationScope.WebresourceSync)); - Assert.Contains("Webresource root path is required", exception.Message); - } - - [Fact] - public void WebresourceSyncOptionsValidator_NonExistentFolderPath_ThrowsValidationException() - { - // Arrange - var options = new WebresourceSyncOptions( - FolderPath: "C:\\NonExistentPath\\Webresources", - SolutionName: "TestSolution" - ); - - // Act & Assert - var validator = new XrmSyncConfigurationValidator(Options.Create(XrmSyncConfiguration.Empty with { Webresource = WebresourceOptions.Empty with { Sync = options } })); - var exception = Assert.Throws(() => validator.Validate(ConfigurationScope.WebresourceSync)); - Assert.Contains("Webresource root path does not exist", exception.Message); - } - - [Fact] - public void WebresourceSyncOptionsValidator_EmptySolutionName_ThrowsValidationException() - { - // Arrange - // Create a test directory - var tempDir = Path.GetTempFileName(); - File.Delete(tempDir); - Directory.CreateDirectory(tempDir); - - var options = new WebresourceSyncOptions( - FolderPath: tempDir, - SolutionName: "" - ); + var tempFile = Path.GetTempFileName(); + File.Move(tempFile, Path.ChangeExtension(tempFile, ".dll")); + var dllPath = Path.ChangeExtension(tempFile, ".dll"); + File.WriteAllText(dllPath, "test content"); try { + var config = new XrmSyncConfiguration( + DryRun: false, + LogLevel: LogLevel.Information, + CiMode: false, + Profiles: new List + { + new("default", "TestSolution", new List + { + new PluginAnalysisSyncItem(dllPath, "a", true) + }) + } + ); + // Act & Assert - var validator = new XrmSyncConfigurationValidator(Options.Create(XrmSyncConfiguration.Empty with { Webresource = WebresourceOptions.Empty with { Sync = options } })); - var exception = Assert.Throws(() => validator.Validate(ConfigurationScope.WebresourceSync)); - Assert.Contains("Solution name is required", exception.Message); + var validator = new XrmSyncConfigurationValidator( + Options.Create(config), + Options.Create(CreateSharedOptions())); + var exception = Assert.Throws( + () => validator.Validate(ConfigurationScope.PluginAnalysis)); + Assert.Contains("Publisher prefix must be between 2 and 8 characters", exception.Message); } finally { - // Cleanup - if (Directory.Exists(tempDir)) - Directory.Delete(tempDir, true); + if (File.Exists(dllPath)) + File.Delete(dllPath); } } - [Fact] - public void WebresourceSyncOptionsValidator_NullSolutionName_ThrowsValidationException() + [Theory] + [InlineData("contoso")] + [InlineData("ms")] + [InlineData("contoso1")] + [InlineData("abc123")] + public void PluginAnalysisValidator_ValidPublisherPrefixes_PassValidation(string publisherPrefix) { - // Arrange - // Create a test directory - var tempDir = Path.GetTempFileName(); - File.Delete(tempDir); - Directory.CreateDirectory(tempDir); - - var options = new WebresourceSyncOptions( - FolderPath: tempDir, - SolutionName: null! - ); + // Arrange - Create a test DLL file + var tempFile = Path.GetTempFileName(); + File.Move(tempFile, Path.ChangeExtension(tempFile, ".dll")); + var dllPath = Path.ChangeExtension(tempFile, ".dll"); + File.WriteAllText(dllPath, "test content"); try { + var config = new XrmSyncConfiguration( + DryRun: false, + LogLevel: LogLevel.Information, + CiMode: false, + Profiles: new List + { + new("default", "TestSolution", new List + { + new PluginAnalysisSyncItem(dllPath, publisherPrefix, true) + }) + } + ); + // Act & Assert - var validator = new XrmSyncConfigurationValidator(Options.Create(XrmSyncConfiguration.Empty with { Webresource = WebresourceOptions.Empty with { Sync = options } })); - var exception = Assert.Throws(() => validator.Validate(ConfigurationScope.WebresourceSync)); - Assert.Contains("Solution name is required", exception.Message); + var validator = new XrmSyncConfigurationValidator( + Options.Create(config), + Options.Create(CreateSharedOptions())); + validator.Validate(ConfigurationScope.PluginAnalysis); // Should not throw } finally { - // Cleanup - if (Directory.Exists(tempDir)) - Directory.Delete(tempDir, true); + if (File.Exists(dllPath)) + File.Delete(dllPath); } } - [Fact] - public void WebresourceSyncOptionsValidator_WhitespaceSolutionName_ThrowsValidationException() + [Theory] + [InlineData("Contoso")] // Starts with uppercase + [InlineData("1contoso")] // Starts with number + [InlineData("con-toso")] // Contains hyphen + public void PluginAnalysisValidator_InvalidPublisherPrefixFormats_ThrowValidationException(string publisherPrefix) { // Arrange - // Create a test directory - var tempDir = Path.GetTempFileName(); - File.Delete(tempDir); - Directory.CreateDirectory(tempDir); - - var options = new WebresourceSyncOptions( - FolderPath: tempDir, - SolutionName: " " - ); + var tempFile = Path.GetTempFileName(); + File.Move(tempFile, Path.ChangeExtension(tempFile, ".dll")); + var dllPath = Path.ChangeExtension(tempFile, ".dll"); + File.WriteAllText(dllPath, "test content"); try { + var config = new XrmSyncConfiguration( + DryRun: false, + LogLevel: LogLevel.Information, + CiMode: false, + Profiles: new List + { + new("default", "TestSolution", new List + { + new PluginAnalysisSyncItem(dllPath, publisherPrefix, true) + }) + } + ); + // Act & Assert - var validator = new XrmSyncConfigurationValidator(Options.Create(XrmSyncConfiguration.Empty with { Webresource = WebresourceOptions.Empty with { Sync = options } })); - var exception = Assert.Throws(() => validator.Validate(ConfigurationScope.WebresourceSync)); - Assert.Contains("Solution name is required", exception.Message); + var validator = new XrmSyncConfigurationValidator( + Options.Create(config), + Options.Create(CreateSharedOptions())); + var exception = Assert.Throws( + () => validator.Validate(ConfigurationScope.PluginAnalysis)); + Assert.Contains("Publisher prefix must start with a lowercase letter", exception.Message); } finally { - // Cleanup - if (Directory.Exists(tempDir)) - Directory.Delete(tempDir, true); + if (File.Exists(dllPath)) + File.Delete(dllPath); } } [Fact] - public void WebresourceSyncOptionsValidator_SolutionNameTooLong_ThrowsValidationException() + public void WebresourceValidator_ValidOptions_PassesValidation() { - // Arrange - // Create a test directory + // Arrange - Create a test directory var tempDir = Path.GetTempFileName(); File.Delete(tempDir); Directory.CreateDirectory(tempDir); - var options = new WebresourceSyncOptions( - FolderPath: tempDir, - SolutionName: new string('a', 66) // 66 characters - ); - try { - // Act & Assert - var validator = new XrmSyncConfigurationValidator(Options.Create(XrmSyncConfiguration.Empty with { Webresource = WebresourceOptions.Empty with { Sync = options } })); - var exception = Assert.Throws(() => validator.Validate(ConfigurationScope.WebresourceSync)); - Assert.Contains("Solution name cannot exceed 65 characters", exception.Message); - } - finally - { - // Cleanup - if (Directory.Exists(tempDir)) - Directory.Delete(tempDir, true); - } - } + var config = new XrmSyncConfiguration( + DryRun: false, + LogLevel: LogLevel.Information, + CiMode: false, + Profiles: new List + { + new("default", "TestSolution", new List + { + new WebresourceSyncItem(tempDir) + }) + } + ); - [Theory] - [InlineData("ValidSolution")] - [InlineData("My_Solution")] - [InlineData("Solution123")] - [InlineData("A")] - [InlineData("AB")] - public void WebresourceSyncOptionsValidator_ValidSolutionNames_PassValidation(string solutionName) - { - // Arrange - // Create a test directory - var tempDir = Path.GetTempFileName(); - File.Delete(tempDir); - Directory.CreateDirectory(tempDir); - - var options = new WebresourceSyncOptions( - FolderPath: tempDir, - SolutionName: solutionName - ); - - try - { // Act & Assert - var validator = new XrmSyncConfigurationValidator(Options.Create(XrmSyncConfiguration.Empty with { Webresource = WebresourceOptions.Empty with { Sync = options } })); + var validator = new XrmSyncConfigurationValidator( + Options.Create(config), + Options.Create(CreateSharedOptions())); validator.Validate(ConfigurationScope.WebresourceSync); // Should not throw } finally { - // Cleanup if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } } [Fact] - public void WebresourceSyncOptionsValidator_RelativeFolderPath_PassesValidation() + public void WebresourceValidator_EmptyFolderPath_ThrowsValidationException() { // Arrange - // Create a relative test directory - var relativeDir = "TestWebresources"; - if (Directory.Exists(relativeDir)) - Directory.Delete(relativeDir, true); - Directory.CreateDirectory(relativeDir); - - var options = new WebresourceSyncOptions( - FolderPath: relativeDir, - SolutionName: "TestSolution" + var config = new XrmSyncConfiguration( + DryRun: false, + LogLevel: LogLevel.Information, + CiMode: false, + Profiles: new List + { + new("default", "TestSolution", new List + { + new WebresourceSyncItem("") + }) + } ); - try - { - // Act & Assert - var validator = new XrmSyncConfigurationValidator(Options.Create(XrmSyncConfiguration.Empty with { Webresource = WebresourceOptions.Empty with { Sync = options } })); - validator.Validate(ConfigurationScope.WebresourceSync); // Should not throw - } - finally - { - // Cleanup - if (Directory.Exists(relativeDir)) - Directory.Delete(relativeDir, true); - } + // Act & Assert + var validator = new XrmSyncConfigurationValidator( + Options.Create(config), + Options.Create(CreateSharedOptions())); + var exception = Assert.Throws( + () => validator.Validate(ConfigurationScope.WebresourceSync)); + Assert.Contains("Webresource root path is required", exception.Message); } [Fact] - public void WebresourceSyncOptionsValidator_DryRunFlags_PassValidation() + public void WebresourceValidator_NonExistentFolderPath_ThrowsValidationException() { // Arrange - // Create a test directory - var tempDir = Path.GetTempFileName(); - File.Delete(tempDir); - Directory.CreateDirectory(tempDir); - - var options = new WebresourceSyncOptions( - FolderPath: tempDir, - SolutionName: "TestSolution" + var config = new XrmSyncConfiguration( + DryRun: false, + LogLevel: LogLevel.Information, + CiMode: false, + Profiles: new List + { + new("default", "TestSolution", new List + { + new WebresourceSyncItem("C:\\NonExistentPath\\Webresources") + }) + } ); - try - { - // Act & Assert - var validator = new XrmSyncConfigurationValidator(Options.Create(XrmSyncConfiguration.Empty with { Webresource = WebresourceOptions.Empty with { Sync = options } })); - validator.Validate(ConfigurationScope.WebresourceSync); // Should not throw - } - finally - { - // Cleanup - if (Directory.Exists(tempDir)) - Directory.Delete(tempDir, true); - } + // Act & Assert + var validator = new XrmSyncConfigurationValidator( + Options.Create(config), + Options.Create(CreateSharedOptions())); + var exception = Assert.Throws( + () => validator.Validate(ConfigurationScope.WebresourceSync)); + Assert.Contains("Webresource root path does not exist", exception.Message); } [Fact] - public void WebresourceSyncOptionsValidator_MultipleValidationErrors_ThrowsExceptionWithAllErrors() + public void Validator_ProfileNotFound_ThrowsValidationException() { // Arrange - var options = new WebresourceSyncOptions( - FolderPath: "", // Invalid: empty - SolutionName: new string('a', 66) // Invalid: too long + var config = new XrmSyncConfiguration( + DryRun: false, + LogLevel: LogLevel.Information, + CiMode: false, + Profiles: new List() ); // Act & Assert - var validator = new XrmSyncConfigurationValidator(Options.Create(XrmSyncConfiguration.Empty with { Webresource = WebresourceOptions.Empty with { Sync = options } })); - var exception = Assert.Throws(() => validator.Validate(ConfigurationScope.WebresourceSync)); - - // Verify both validation errors are present - Assert.Contains("Webresource root path is required", exception.Message); - Assert.Contains("Solution name cannot exceed 65 characters", exception.Message); + var validator = new XrmSyncConfigurationValidator( + Options.Create(config), + Options.Create(CreateSharedOptions("nonexistent"))); + var exception = Assert.Throws( + () => validator.Validate(ConfigurationScope.PluginSync)); + Assert.Contains("Profile 'nonexistent' not found", exception.Message); } -} \ No newline at end of file +} diff --git a/Tests/Logging/CIModeDemonstrationTests.cs b/Tests/Logging/CIModeDemonstrationTests.cs index 2509242..9590e51 100644 --- a/Tests/Logging/CIModeDemonstrationTests.cs +++ b/Tests/Logging/CIModeDemonstrationTests.cs @@ -16,14 +16,16 @@ public void Demonstrate_CIModePassedToFormatter() // Arrange - Create service provider with CI mode enabled via AddLogger parameter var services = new ServiceCollection() - .AddSingleton(Options.Create(XrmSyncConfiguration.Empty with - { - Logger = new (LogLevel.Debug, true) // Explicitly enable CI mode - })) + .AddSingleton(Options.Create(new XrmSyncConfiguration( + DryRun: false, + LogLevel: LogLevel.Debug, + CiMode: true, // Explicitly enable CI mode + Profiles: new List() + ))) .AddLogger() .BuildServiceProvider(); - var configOptions = services.GetRequiredService>(); + var configOptions = services.GetRequiredService>(); // Capture what the logger would send to the formatter var capturedMessages = new List<(LogLevel Level, string Message)>(); @@ -87,4 +89,4 @@ public void AddProvider(ILoggerProvider provider) { } public ILogger CreateLogger(string categoryName) => _logger; public void Dispose() { } } -} \ No newline at end of file +} diff --git a/Tests/Logging/SyncLoggerTests.cs b/Tests/Logging/SyncLoggerTests.cs index 6bf04d1..ab907e0 100644 --- a/Tests/Logging/SyncLoggerTests.cs +++ b/Tests/Logging/SyncLoggerTests.cs @@ -37,8 +37,8 @@ public void Log_PassesThroughDirectly() { // Arrange var loggerFactory = new TestLoggerFactory(); - var loggerOptions = new LoggerOptions(LogLevel.Information, false); - var syncLogger = new SyncLogger(loggerFactory, Options.Create(loggerOptions)); + var config = new XrmSyncConfiguration(false, LogLevel.Information, false, new List()); + var syncLogger = new SyncLogger(loggerFactory, Options.Create(config)); // Act syncLogger.LogWarning("This is a warning message"); @@ -57,8 +57,8 @@ public void Log_AllLogLevels_PassThroughDirectly() { // Arrange var loggerFactory = new TestLoggerFactory(); - var loggerOptions = new LoggerOptions(LogLevel.Information, false); - var syncLogger = new SyncLogger(loggerFactory, Options.Create(loggerOptions)); + var config = new XrmSyncConfiguration(false, LogLevel.Information, false, new List()); + var syncLogger = new SyncLogger(loggerFactory, Options.Create(config)); // Act syncLogger.LogWarning("This is a warning message"); diff --git a/Tests/Plugins/DifferenceUtilityTests.cs b/Tests/Plugins/DifferenceUtilityTests.cs index 81f18a7..570c318 100644 --- a/Tests/Plugins/DifferenceUtilityTests.cs +++ b/Tests/Plugins/DifferenceUtilityTests.cs @@ -21,7 +21,7 @@ public DifferenceUtilityTests() { var logger = new LoggerFactory().CreateLogger(); var description = new Description(); - var options = new ExecutionOptions(true); + var options = new ExecutionModeOptions(true); _differenceUtility = new DifferenceCalculator( new PluginDefinitionComparer(), new PluginStepComparer(), diff --git a/Tests/Plugins/PluginServiceTests.cs b/Tests/Plugins/PluginServiceTests.cs index db96b12..0995e74 100644 --- a/Tests/Plugins/PluginServiceTests.cs +++ b/Tests/Plugins/PluginServiceTests.cs @@ -29,7 +29,7 @@ public class PluginServiceTests private readonly IDifferenceCalculator _differenceUtility = Substitute.For(); private readonly IDescription _description = new Description(); private readonly IPrintService _printService = Substitute.For(); - private readonly PluginSyncOptions _options = new(string.Empty, "solution"); + private readonly PluginSyncCommandOptions _options = new(string.Empty, "solution"); private readonly PluginSyncService _plugin; diff --git a/Tests/Webresources/WebresourceSyncServiceTests.cs b/Tests/Webresources/WebresourceSyncServiceTests.cs index 0170ae6..a90ef17 100644 --- a/Tests/Webresources/WebresourceSyncServiceTests.cs +++ b/Tests/Webresources/WebresourceSyncServiceTests.cs @@ -21,7 +21,7 @@ public class WebresourceSyncServiceTests private readonly IWebresourceWriter _webresourceWriter = Substitute.For(); private readonly IPrintService _printService = Substitute.For(); private readonly IValidator _webresourceValidator = Substitute.For>(); - private readonly WebresourceSyncOptions _options = new("C:\\WebResources", "TestSolution"); + private readonly WebresourceSyncCommandOptions _options = new("C:\\WebResources", "TestSolution"); private readonly WebresourceSyncService _service; diff --git a/Tests/Webresources/WebresourceWriterTests.cs b/Tests/Webresources/WebresourceWriterTests.cs index b48492a..5fa19d1 100644 --- a/Tests/Webresources/WebresourceWriterTests.cs +++ b/Tests/Webresources/WebresourceWriterTests.cs @@ -11,7 +11,7 @@ namespace Tests.Webresources; public class WebresourceWriterTests { private readonly IDataverseWriter _dataverseWriter = Substitute.For(); - private readonly WebresourceSyncOptions _options = new("C:\\WebResources", "TestSolution"); + private readonly WebresourceSyncCommandOptions _options = new("C:\\WebResources", "TestSolution"); private readonly WebresourceWriter _writer; public WebresourceWriterTests() diff --git a/XrmSync/Commands/PluginAnalyzeCommand.cs b/XrmSync/Commands/PluginAnalyzeCommand.cs index 92c3b42..85d12b7 100644 --- a/XrmSync/Commands/PluginAnalyzeCommand.cs +++ b/XrmSync/Commands/PluginAnalyzeCommand.cs @@ -24,13 +24,13 @@ public PluginAnalyzeCommand() : base("analyze", "Analyze a plugin assembly and o _assemblyFile = new(CliOptions.Assembly.Primary, CliOptions.Assembly.Aliases) { Description = CliOptions.Assembly.Description, - Arity = ArgumentArity.ExactlyOne + Arity = ArgumentArity.ZeroOrOne }; _prefix = new(CliOptions.Analysis.Prefix.Primary, CliOptions.Analysis.Prefix.Aliases) { Description = CliOptions.Analysis.Prefix.Description, - Arity = ArgumentArity.ExactlyOne + Arity = ArgumentArity.ZeroOrOne }; _prettyPrint = new(CliOptions.Analysis.PrettyPrint.Primary, CliOptions.Analysis.PrettyPrint.Aliases) @@ -57,20 +57,55 @@ private async Task ExecuteAsync(ParseResult parseResult, CancellationToken // Build service provider var serviceProvider = GetAnalyzerServices() .AddXrmSyncConfiguration(sharedOptions) - .AddOptions( - baseOptions => baseOptions with + .AddOptions(baseOptions => baseOptions) + .AddSingleton(sp => + { + var config = sp.GetRequiredService>().Value; + + // Determine assembly path, publisher prefix, and pretty print + string finalAssemblyPath; + string finalPublisherPrefix; + bool finalPrettyPrint; + + // If CLI options provided, use them (standalone mode) + if (!string.IsNullOrWhiteSpace(assemblyPath) && !string.IsNullOrWhiteSpace(publisherPrefix)) { - Plugin = baseOptions.Plugin with + finalAssemblyPath = assemblyPath; + finalPublisherPrefix = publisherPrefix; + finalPrettyPrint = prettyPrint; + } + // Otherwise try to get from profile + else + { + var profile = config.Profiles.FirstOrDefault(p => + p.Name.Equals(sharedOptions.ProfileName, StringComparison.OrdinalIgnoreCase)); + + if (profile == null) { - Analysis = new( - string.IsNullOrWhiteSpace(assemblyPath) ? baseOptions.Plugin.Analysis.AssemblyPath : assemblyPath, - string.IsNullOrWhiteSpace(publisherPrefix) ? baseOptions.Plugin.Analysis.PublisherPrefix : publisherPrefix, - prettyPrint || baseOptions.Plugin.Analysis.PrettyPrint - ) + throw new InvalidOperationException( + $"Profile '{sharedOptions.ProfileName}' not found. " + + "Either specify --assembly and --publisher-prefix, or use --profile with a valid profile name."); } + + var pluginAnalysisItem = profile.Sync.OfType().FirstOrDefault(); + if (pluginAnalysisItem == null) + { + throw new InvalidOperationException( + $"Profile '{profile.Name}' does not contain a PluginAnalysis sync item. " + + "Either specify --assembly and --publisher-prefix, or use a profile with a PluginAnalysis sync item."); + } + + finalAssemblyPath = !string.IsNullOrWhiteSpace(assemblyPath) + ? assemblyPath + : pluginAnalysisItem.AssemblyPath; + finalPublisherPrefix = !string.IsNullOrWhiteSpace(publisherPrefix) + ? publisherPrefix + : pluginAnalysisItem.PublisherPrefix; + finalPrettyPrint = prettyPrint || pluginAnalysisItem.PrettyPrint; } - ) - .AddCommandOptions(c => c.Plugin.Analysis) + + return Microsoft.Extensions.Options.Options.Create(new PluginAnalysisCommandOptions(finalAssemblyPath, finalPublisherPrefix, finalPrettyPrint)); + }) .AddLogger() .BuildServiceProvider(); @@ -86,7 +121,7 @@ private static async Task CommandAction(IServiceProvider serviceProvider, try { var analyzer = serviceProvider.GetRequiredService(); - var configuration = serviceProvider.GetRequiredService>(); + var configuration = serviceProvider.GetRequiredService>(); var pluginDto = analyzer.AnalyzeAssembly(configuration.Value.AssemblyPath, configuration.Value.PublisherPrefix); var jsonOptions = new JsonSerializerOptions(JsonSerializerOptions.Default) diff --git a/XrmSync/Commands/PluginSyncCommand.cs b/XrmSync/Commands/PluginSyncCommand.cs index f95618f..592e439 100644 --- a/XrmSync/Commands/PluginSyncCommand.cs +++ b/XrmSync/Commands/PluginSyncCommand.cs @@ -1,7 +1,9 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using System.CommandLine; using XrmSync.Constants; using XrmSync.Extensions; +using XrmSync.Model; using XrmSync.Options; using XrmSync.SyncService.Extensions; @@ -16,7 +18,7 @@ public PluginSyncCommand() : base("plugins", "Synchronize plugins in a plugin as _assemblyFile = new(CliOptions.Assembly.Primary, CliOptions.Assembly.Aliases) { Description = CliOptions.Assembly.Description, - Arity = ArgumentArity.ExactlyOne + Arity = ArgumentArity.ZeroOrOne }; Add(_assemblyFile); @@ -32,31 +34,67 @@ private async Task ExecuteAsync(ParseResult parseResult, CancellationToken var assemblyPath = parseResult.GetValue(_assemblyFile); var (solutionName, dryRun, logLevel, ciMode) = GetSyncSharedOptionValues(parseResult); var sharedOptions = GetSharedOptionValues(parseResult); - + // Build service provider var serviceProvider = GetPluginSyncServices() .AddXrmSyncConfiguration(sharedOptions) .AddOptions( baseOptions => baseOptions with { - Logger = baseOptions.Logger with - { - LogLevel = logLevel ?? baseOptions.Logger.LogLevel, - CiMode = ciMode ?? baseOptions.Logger.CiMode - }, - Execution = baseOptions.Execution with + LogLevel = logLevel ?? baseOptions.LogLevel, + CiMode = ciMode ?? baseOptions.CiMode, + DryRun = dryRun ?? baseOptions.DryRun + }) + .AddSingleton(sp => + { + var config = sp.GetRequiredService>().Value; + + // Determine assembly path and solution name + string finalAssemblyPath; + string finalSolutionName; + + // If CLI options provided, use them (standalone mode) + if (!string.IsNullOrWhiteSpace(assemblyPath) && !string.IsNullOrWhiteSpace(solutionName)) + { + finalAssemblyPath = assemblyPath; + finalSolutionName = solutionName; + } + // Otherwise try to get from profile + else + { + var profile = config.Profiles.FirstOrDefault(p => + p.Name.Equals(sharedOptions.ProfileName, StringComparison.OrdinalIgnoreCase)); + + if (profile == null) { - DryRun = dryRun ?? baseOptions.Execution.DryRun - }, - Plugin = baseOptions.Plugin with + throw new InvalidOperationException( + $"Profile '{sharedOptions.ProfileName}' not found. " + + "Either specify --assembly and --solution, or use --profile with a valid profile name."); + } + + var pluginSyncItem = profile.Sync.OfType().FirstOrDefault(); + if (pluginSyncItem == null) { - Sync = new( - string.IsNullOrWhiteSpace(assemblyPath) ? baseOptions.Plugin.Sync.AssemblyPath : assemblyPath, - string.IsNullOrWhiteSpace(solutionName) ? baseOptions.Plugin.Sync.SolutionName : solutionName - ) + throw new InvalidOperationException( + $"Profile '{profile.Name}' does not contain a Plugin sync item. " + + "Either specify --assembly and --solution, or use a profile with a Plugin sync item."); } - }) - .AddCommandOptions(c => c.Plugin.Sync) + + finalAssemblyPath = !string.IsNullOrWhiteSpace(assemblyPath) + ? assemblyPath + : pluginSyncItem.AssemblyPath; + finalSolutionName = !string.IsNullOrWhiteSpace(solutionName) + ? solutionName + : profile.SolutionName; + } + + return Microsoft.Extensions.Options.Options.Create(new PluginSyncCommandOptions(finalAssemblyPath, finalSolutionName)); + }) + .AddSingleton(sp => + { + var config = sp.GetRequiredService>().Value; + return Microsoft.Extensions.Options.Options.Create(new ExecutionModeOptions(config.DryRun)); + }) .AddLogger() .BuildServiceProvider(); diff --git a/XrmSync/Commands/WebresourceSyncCommand.cs b/XrmSync/Commands/WebresourceSyncCommand.cs index 82770fa..793f9e6 100644 --- a/XrmSync/Commands/WebresourceSyncCommand.cs +++ b/XrmSync/Commands/WebresourceSyncCommand.cs @@ -1,7 +1,9 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using System.CommandLine; using XrmSync.Constants; using XrmSync.Extensions; +using XrmSync.Model; using XrmSync.Options; using XrmSync.SyncService.Extensions; @@ -16,7 +18,7 @@ public WebresourceSyncCommand() : base("webresources", "Synchronize webresources _webresourceRoot = new(CliOptions.Webresource.Primary, CliOptions.Webresource.Aliases) { Description = CliOptions.Webresource.Description, - Arity = ArgumentArity.ExactlyOne + Arity = ArgumentArity.ZeroOrOne }; Add(_webresourceRoot); @@ -39,25 +41,61 @@ private async Task ExecuteAsync(ParseResult parseResult, CancellationToken .AddOptions( options => options with { - Logger = options.Logger with - { - LogLevel = logLevel ?? options.Logger.LogLevel, - CiMode = ciMode ?? options.Logger.CiMode - }, - Execution = options.Execution with + LogLevel = logLevel ?? options.LogLevel, + CiMode = ciMode ?? options.CiMode, + DryRun = dryRun ?? options.DryRun + } + ) + .AddSingleton(sp => + { + var config = sp.GetRequiredService>().Value; + + // Determine folder path and solution name + string finalFolderPath; + string finalSolutionName; + + // If CLI options provided, use them (standalone mode) + if (!string.IsNullOrWhiteSpace(folderPath) && !string.IsNullOrWhiteSpace(solutionName)) + { + finalFolderPath = folderPath; + finalSolutionName = solutionName; + } + // Otherwise try to get from profile + else + { + var profile = config.Profiles.FirstOrDefault(p => + p.Name.Equals(sharedOptions.ProfileName, StringComparison.OrdinalIgnoreCase)); + + if (profile == null) { - DryRun = dryRun ?? options.Execution.DryRun - }, - Webresource = options.Webresource with + throw new InvalidOperationException( + $"Profile '{sharedOptions.ProfileName}' not found. " + + "Either specify --folder and --solution, or use --profile with a valid profile name."); + } + + var webresourceSyncItem = profile.Sync.OfType().FirstOrDefault(); + if (webresourceSyncItem == null) { - Sync = new( - string.IsNullOrWhiteSpace(folderPath) ? options.Webresource.Sync.FolderPath : folderPath, - string.IsNullOrWhiteSpace(solutionName) ? options.Webresource.Sync.SolutionName : solutionName - ) + throw new InvalidOperationException( + $"Profile '{profile.Name}' does not contain a Webresource sync item. " + + "Either specify --folder and --solution, or use a profile with a Webresource sync item."); } + + finalFolderPath = !string.IsNullOrWhiteSpace(folderPath) + ? folderPath + : webresourceSyncItem.FolderPath; + finalSolutionName = !string.IsNullOrWhiteSpace(solutionName) + ? solutionName + : profile.SolutionName; } - ) - .AddCommandOptions(config => config.Webresource.Sync) + + return Microsoft.Extensions.Options.Options.Create(new WebresourceSyncCommandOptions(finalFolderPath, finalSolutionName)); + }) + .AddSingleton(sp => + { + var config = sp.GetRequiredService>().Value; + return Microsoft.Extensions.Options.Options.Create(new ExecutionModeOptions(config.DryRun)); + }) .AddLogger() .BuildServiceProvider(); diff --git a/XrmSync/Commands/XrmSyncCommandBase.cs b/XrmSync/Commands/XrmSyncCommandBase.cs index 0f07be3..0159699 100644 --- a/XrmSync/Commands/XrmSyncCommandBase.cs +++ b/XrmSync/Commands/XrmSyncCommandBase.cs @@ -18,12 +18,12 @@ internal abstract class XrmSyncCommandBase(string name, string description) : Co // Shared options available to all commands protected Option SaveConfigOption { get; private set; } = null!; protected Option SaveConfigToOption { get; private set; } = null!; - protected Option ConfigNameOption { get; private set; } = null!; + protected Option ProfileNameOption { get; private set; } = null!; public Command GetCommand() => this; /// - /// Adds shared options to the command (save-config, save-config-to, config-name) + /// Adds shared options to the command (save-config, save-config-to, profile) /// protected void AddSharedOptions() { @@ -39,7 +39,7 @@ protected void AddSharedOptions() Required = false }; - ConfigNameOption = new(CliOptions.Config.LoadConfig.Primary, CliOptions.Config.LoadConfig.Aliases) + ProfileNameOption = new(CliOptions.Config.LoadConfig.Primary, CliOptions.Config.LoadConfig.Aliases) { Description = CliOptions.Config.LoadConfig.Description, Required = false @@ -47,7 +47,7 @@ protected void AddSharedOptions() Add(SaveConfigOption); Add(SaveConfigToOption); - Add(ConfigNameOption); + Add(ProfileNameOption); } /// @@ -57,9 +57,9 @@ protected SharedOptions GetSharedOptionValues(ParseResult parseResult) { var saveConfig = parseResult.GetValue(SaveConfigOption); var saveConfigTo = saveConfig ? parseResult.GetValue(SaveConfigToOption) ?? ConfigReader.CONFIG_FILE_BASE + ".json" : null; - var configName = parseResult.GetValue(ConfigNameOption) ?? XrmSyncConfigurationBuilder.DEFAULT_CONFIG_NAME; + var profileName = parseResult.GetValue(ProfileNameOption) ?? XrmSyncConfigurationBuilder.DEFAULT_PROFILE_NAME; - return new (saveConfig, saveConfigTo, configName); + return new (saveConfig, saveConfigTo, profileName); } /// @@ -85,14 +85,14 @@ protected static async Task RunAction( var sharedOptions = serviceProvider.GetRequiredService>(); - var (saveConfig, saveConfigTo, configName) = sharedOptions.Value; + var (saveConfig, saveConfigTo, _) = sharedOptions.Value; if (saveConfig) { var configWriter = serviceProvider.GetRequiredService(); var configPath = string.IsNullOrWhiteSpace(saveConfigTo) ? null : saveConfigTo; - - await configWriter.SaveConfig(configPath, configName, cancellationToken); + + await configWriter.SaveConfig(configPath, cancellationToken); Console.WriteLine($"Configuration saved to {saveConfigTo}"); return true; } diff --git a/XrmSync/Commands/XrmSyncRootCommand.cs b/XrmSync/Commands/XrmSyncRootCommand.cs index b025f13..8827872 100644 --- a/XrmSync/Commands/XrmSyncRootCommand.cs +++ b/XrmSync/Commands/XrmSyncRootCommand.cs @@ -16,7 +16,7 @@ namespace XrmSync.Commands; internal record ArgumentOverrides(bool DryRun, bool CiMode, LogLevel? LogLevel); /// -/// Root command handler that executes all configured sub-commands for a given configuration +/// Root command handler that executes all sync items in a profile /// internal class XrmSyncRootCommand : XrmSyncCommandBase { @@ -66,20 +66,14 @@ private async Task ExecuteAsync(ParseResult parseResult, CancellationToken var ciModeOverride = parseResult.GetValue(_ciMode); var logLevelOverride = parseResult.GetValue(_logLevel); - // Build service provider using the same pattern as other commands + // Build service provider var serviceProvider = new ServiceCollection() .AddXrmSyncConfiguration(sharedOptions) .AddOptions(baseOptions => baseOptions with { - Logger = baseOptions.Logger with - { - LogLevel = logLevelOverride ?? baseOptions.Logger.LogLevel, - CiMode = ciModeOverride || baseOptions.Logger.CiMode - }, - Execution = baseOptions.Execution with - { - DryRun = dryRunOverride || baseOptions.Execution.DryRun - } + LogLevel = logLevelOverride ?? baseOptions.LogLevel, + CiMode = ciModeOverride || baseOptions.CiMode, + DryRun = dryRunOverride || baseOptions.DryRun }) .AddLogger() .BuildServiceProvider(); @@ -87,106 +81,134 @@ private async Task ExecuteAsync(ParseResult parseResult, CancellationToken var xrmSyncConfig = serviceProvider.GetRequiredService>().Value; var logger = serviceProvider.GetRequiredService>(); - logger.LogInformation("Running XrmSync with configuration: {configName} (DryRun: {dryRun})", - sharedOptions.ConfigName, xrmSyncConfig.Execution.DryRun); + // Find the profile + var profile = xrmSyncConfig.Profiles.FirstOrDefault(p => + p.Name.Equals(sharedOptions.ProfileName, StringComparison.OrdinalIgnoreCase)); + + if (profile == null) + { + logger.LogError("Profile '{profileName}' not found. Use 'xrmsync config list' to see available profiles.", sharedOptions.ProfileName); + return E_ERROR; + } + + logger.LogInformation("Running XrmSync with profile: {profileName} (DryRun: {dryRun})", + profile.Name, xrmSyncConfig.DryRun); + + if (profile.Sync.Count == 0) + { + logger.LogWarning("Profile '{profileName}' has no sync items configured. Nothing to execute.", profile.Name); + return E_ERROR; + } var success = true; - var executedAnyCommand = false; // Create argument overrides DTO var overrides = new ArgumentOverrides(dryRunOverride, ciModeOverride, logLevelOverride); - // Execute plugin sync if configured - if (!string.IsNullOrWhiteSpace(xrmSyncConfig.Plugin.Sync.AssemblyPath) && - !string.IsNullOrWhiteSpace(xrmSyncConfig.Plugin.Sync.SolutionName)) + // Execute each sync item in the profile + foreach (var syncItem in profile.Sync) { - logger.LogInformation("Executing plugin sync..."); - var pluginSyncArgs = BuildPluginSyncArgs(xrmSyncConfig, sharedOptions, overrides); - var result = await ExecuteSubCommand("plugins", pluginSyncArgs); - success = success && result == E_OK; - executedAnyCommand = true; - } + logger.LogInformation("Executing {syncType} sync item...", syncItem.SyncType); - // Execute webresource sync if configured - if (!string.IsNullOrWhiteSpace(xrmSyncConfig.Webresource.Sync.FolderPath) && - !string.IsNullOrWhiteSpace(xrmSyncConfig.Webresource.Sync.SolutionName)) - { - logger.LogInformation("Executing webresource sync..."); - var webresourceSyncArgs = BuildWebresourceSyncArgs(xrmSyncConfig, sharedOptions, overrides); - var result = await ExecuteSubCommand("webresources", webresourceSyncArgs); - success = success && result == E_OK; - executedAnyCommand = true; - } + int result = syncItem switch + { + PluginSyncItem plugin => await ExecutePluginSync(plugin, profile, sharedOptions, overrides, xrmSyncConfig), + PluginAnalysisSyncItem analysis => await ExecutePluginAnalysis(analysis, sharedOptions, overrides, xrmSyncConfig), + WebresourceSyncItem webresource => await ExecuteWebresourceSync(webresource, profile, sharedOptions, overrides, xrmSyncConfig), + _ => LogUnknownSyncItemType(logger, syncItem.SyncType) + }; - if (!executedAnyCommand) - { - logger.LogWarning("No sub-commands configured for configuration '{configName}'. Nothing to execute.", sharedOptions.ConfigName); - return E_ERROR; + success = success && result == E_OK; } return success ? E_OK : E_ERROR; } - private async Task ExecuteSubCommand(string commandName, string[] args) + private async Task ExecutePluginSync( + PluginSyncItem syncItem, + ProfileConfiguration profile, + SharedOptions sharedOptions, + ArgumentOverrides overrides, + XrmSyncConfiguration config) { - var command = _subCommands.FirstOrDefault(c => c.GetCommand().Name == commandName); - if (command == null) + var args = new List { - Console.Error.WriteLine($"Sub-command '{commandName}' not found."); - return E_ERROR; - } + CliOptions.Assembly.Primary, syncItem.AssemblyPath, + CliOptions.Solution.Primary, profile.SolutionName, + CliOptions.Config.LoadConfig.Primary, sharedOptions.ProfileName + }; - var parseResult = command.GetCommand().Parse(args); - return await parseResult.InvokeAsync(); + AddCommonArgs(args, overrides, config); + return await ExecuteSubCommand("plugins", [.. args]); } - private static string[] BuildPluginSyncArgs( - XrmSyncConfiguration config, + private async Task ExecutePluginAnalysis( + PluginAnalysisSyncItem syncItem, SharedOptions sharedOptions, - ArgumentOverrides overrides) + ArgumentOverrides overrides, + XrmSyncConfiguration config) { var args = new List { - CliOptions.Assembly.Primary, config.Plugin.Sync.AssemblyPath, - CliOptions.Solution.Primary, config.Plugin.Sync.SolutionName, - CliOptions.Config.LoadConfig.Primary, sharedOptions.ConfigName + CliOptions.Assembly.Primary, syncItem.AssemblyPath, + CliOptions.Analysis.Prefix.Primary, syncItem.PublisherPrefix, + CliOptions.Config.LoadConfig.Primary, sharedOptions.ProfileName }; - // Use override if provided, otherwise use config value - if (overrides.DryRun || config.Execution.DryRun) - args.Add(CliOptions.Execution.DryRun.Primary); + if (syncItem.PrettyPrint) + args.Add(CliOptions.Analysis.PrettyPrint.Primary); - if (overrides.CiMode || config.Logger.CiMode) - args.Add(CliOptions.Logging.CiMode.Aliases[0]); - - var logLevel = overrides.LogLevel ?? config.Logger.LogLevel; - args.AddRange([CliOptions.Logging.LogLevel.Primary, logLevel.ToString()]); - - return [.. args]; + AddCommonArgs(args, overrides, config); + return await ExecuteSubCommand("analyze", [.. args]); } - private static string[] BuildWebresourceSyncArgs( - XrmSyncConfiguration config, + private async Task ExecuteWebresourceSync( + WebresourceSyncItem syncItem, + ProfileConfiguration profile, SharedOptions sharedOptions, - ArgumentOverrides overrides) + ArgumentOverrides overrides, + XrmSyncConfiguration config) { var args = new List { - CliOptions.Webresource.Primary, config.Webresource.Sync.FolderPath, - CliOptions.Solution.Primary, config.Webresource.Sync.SolutionName, - CliOptions.Config.LoadConfig.Primary, sharedOptions.ConfigName + CliOptions.Webresource.Primary, syncItem.FolderPath, + CliOptions.Solution.Primary, profile.SolutionName, + CliOptions.Config.LoadConfig.Primary, sharedOptions.ProfileName }; + AddCommonArgs(args, overrides, config); + return await ExecuteSubCommand("webresources", [.. args]); + } + + private static void AddCommonArgs(List args, ArgumentOverrides overrides, XrmSyncConfiguration config) + { // Use override if provided, otherwise use config value - if (overrides.DryRun || config.Execution.DryRun) + if (overrides.DryRun || config.DryRun) args.Add(CliOptions.Execution.DryRun.Primary); - if (overrides.CiMode || config.Logger.CiMode) + if (overrides.CiMode || config.CiMode) args.Add(CliOptions.Logging.CiMode.Aliases[0]); - var logLevel = overrides.LogLevel ?? config.Logger.LogLevel; + var logLevel = overrides.LogLevel ?? config.LogLevel; args.AddRange([CliOptions.Logging.LogLevel.Primary, logLevel.ToString()]); + } + + private static int LogUnknownSyncItemType(ILogger logger, string syncType) + { + logger.LogError("Unknown sync item type: {syncType}", syncType); + return E_ERROR; + } - return [.. args]; + private async Task ExecuteSubCommand(string commandName, string[] args) + { + var command = _subCommands.FirstOrDefault(c => c.GetCommand().Name == commandName); + if (command == null) + { + Console.Error.WriteLine($"Sub-command '{commandName}' not found."); + return E_ERROR; + } + + var parseResult = command.GetCommand().Parse(args); + return await parseResult.InvokeAsync(); } } diff --git a/XrmSync/Constants/CliOptions.cs b/XrmSync/Constants/CliOptions.cs index 64b3821..e080d45 100644 --- a/XrmSync/Constants/CliOptions.cs +++ b/XrmSync/Constants/CliOptions.cs @@ -89,9 +89,9 @@ public static class SaveConfigTo public static class LoadConfig { - public const string Primary = "--config"; - public static readonly string[] Aliases = ["--config-name", "-c"]; - public const string Description = "Name of the configuration to load from appsettings.json (Default: 'default' or single config if only one exists)"; + public const string Primary = "--profile"; + public static readonly string[] Aliases = ["--profile-name", "-p"]; + public const string Description = "Name of the profile to load from appsettings.json (Default: 'default' or single profile if only one exists)"; } } diff --git a/XrmSync/Extensions/ServiceCollectionExtensions.cs b/XrmSync/Extensions/ServiceCollectionExtensions.cs index 566219d..96759d9 100644 --- a/XrmSync/Extensions/ServiceCollectionExtensions.cs +++ b/XrmSync/Extensions/ServiceCollectionExtensions.cs @@ -38,12 +38,6 @@ public static IServiceCollection AddOptions( return MSOptions.Create(configModifier(baseConfig)); }); - services.AddSingleton(sp => - { - var config = sp.GetRequiredService>(); - return MSOptions.Create(config.Value.Execution); - }); - return services; } @@ -63,31 +57,23 @@ public static IServiceCollection AddCommandOptions( public static IServiceCollection AddLogger(this IServiceCollection services) { - // Register IOptions for logging services.AddSingleton(sp => { var config = sp.GetRequiredService>(); - return MSOptions.Create(config.Value.Logger); - }); - - services.AddSingleton(sp => - { - var loggerOptions = sp.GetRequiredService>().Value; - var executionOptions = sp.GetRequiredService>().Value; return LoggerFactory.Create( builder => { builder.AddFilter(nameof(Microsoft), LogLevel.Warning) .AddFilter(nameof(System), LogLevel.Warning) - .AddFilter(nameof(XrmSync), loggerOptions.LogLevel) + .AddFilter(nameof(XrmSync), config.Value.LogLevel) .AddConsole(options => options.FormatterName = "ci-console") .AddConsoleFormatter(options => { options.IncludeScopes = false; options.SingleLine = true; options.TimestampFormat = "HH:mm:ss "; - options.CIMode = loggerOptions.CiMode; - options.DryRun = executionOptions.DryRun; + options.CIMode = config.Value.CiMode; + options.DryRun = config.Value.DryRun; }); }); }); diff --git a/XrmSync/Logging/SyncLogger.cs b/XrmSync/Logging/SyncLogger.cs index 2be646d..8d3d963 100644 --- a/XrmSync/Logging/SyncLogger.cs +++ b/XrmSync/Logging/SyncLogger.cs @@ -8,7 +8,7 @@ internal class SyncLogger : ILogger { private readonly ILogger _logger; - public SyncLogger(ILoggerFactory loggerFactory, IOptions configuration) + public SyncLogger(ILoggerFactory loggerFactory, IOptions configuration) { LogLevel minLogLevel = configuration.Value.LogLevel; diff --git a/XrmSync/Options/ConfigValidationOutput.cs b/XrmSync/Options/ConfigValidationOutput.cs index 5c09a7b..4676cfa 100644 --- a/XrmSync/Options/ConfigValidationOutput.cs +++ b/XrmSync/Options/ConfigValidationOutput.cs @@ -15,65 +15,57 @@ public Task OutputValidationResult(CancellationToken cancellationToken = default { if (configOptions == null || sharedOptions == null) { - throw new InvalidOperationException("ConfigValidationOutput requires XrmSyncConfiguration and SharedOptions to validate configuration. Use OutputConfigList for listing configurations."); + throw new InvalidOperationException("ConfigValidationOutput requires XrmSyncConfiguration and SharedOptions to validate configuration. Use OutputConfigList for listing profiles."); } - var configName = sharedOptions.Value.ConfigName; + var profileName = sharedOptions.Value.ProfileName; var configSource = GetConfigurationSource(); - Console.WriteLine($"Configuration: '{configName}' (from {configSource})"); - Console.WriteLine(); - var config = configOptions.Value; - var allValid = true; + var profile = config.Profiles.FirstOrDefault(p => p.Name.Equals(profileName, StringComparison.OrdinalIgnoreCase)); - // Validate and display Plugin Sync Configuration - allValid &= OutputSectionValidation( - "Plugin Sync Configuration", - ConfigurationScope.PluginSync, - () => - { - Console.WriteLine($" Assembly Path: {config.Plugin.Sync.AssemblyPath}"); - Console.WriteLine($" Solution Name: {config.Plugin.Sync.SolutionName}"); - }, - $"{XrmSyncConfigurationBuilder.SectionName.Plugin}:{XrmSyncConfigurationBuilder.SectionName.Sync}"); - - // Validate and display Plugin Analysis Configuration - allValid &= OutputSectionValidation( - "Plugin Analysis Configuration", - ConfigurationScope.PluginAnalysis, - () => - { - Console.WriteLine($" Assembly Path: {config.Plugin.Analysis.AssemblyPath}"); - Console.WriteLine($" Publisher Prefix: {config.Plugin.Analysis.PublisherPrefix}"); - Console.WriteLine($" Pretty Print: {config.Plugin.Analysis.PrettyPrint}"); - }, - $"{XrmSyncConfigurationBuilder.SectionName.Plugin}:{XrmSyncConfigurationBuilder.SectionName.Analysis}"); - - // Validate and display Webresource Sync Configuration - allValid &= OutputSectionValidation( - "Webresource Sync Configuration", - ConfigurationScope.WebresourceSync, - () => - { - Console.WriteLine($" Folder Path: {config.Webresource.Sync.FolderPath}"); - Console.WriteLine($" Solution Name: {config.Webresource.Sync.SolutionName}"); - }, - $"{XrmSyncConfigurationBuilder.SectionName.Webresource}:{XrmSyncConfigurationBuilder.SectionName.Sync}"); - - // Display Logger Configuration (always valid) - Console.WriteLine("✓ Logger Configuration"); - Console.WriteLine($" Log Level: {config.Logger.LogLevel}"); - Console.WriteLine($" CI Mode: {config.Logger.CiMode}"); + if (profile == null) + { + Console.WriteLine($"Profile '{profileName}' not found in {configSource}"); + return Task.CompletedTask; + } + + Console.WriteLine($"Profile: '{profile.Name}' (from {configSource})"); Console.WriteLine(); - // Display Execution Configuration (always valid) - Console.WriteLine("✓ Execution Configuration"); - Console.WriteLine($" Dry Run: {config.Execution.DryRun}"); + // Display global settings + Console.WriteLine("✓ Global Configuration"); + Console.WriteLine($" Dry Run: {config.DryRun}"); + Console.WriteLine($" Log Level: {config.LogLevel}"); + Console.WriteLine($" CI Mode: {config.CiMode}"); Console.WriteLine(); - // Display available commands based on configuration - var availableCommands = GetAvailableCommands(); + // Display profile settings + Console.WriteLine($"✓ Profile '{profile.Name}'"); + Console.WriteLine($" Solution Name: {profile.SolutionName}"); + Console.WriteLine(); + + // Display and validate sync items + var allValid = true; + if (profile.Sync.Count == 0) + { + Console.WriteLine(" ⊘ No sync items configured"); + Console.WriteLine(); + } + else + { + Console.WriteLine($" Sync Items ({profile.Sync.Count}):"); + Console.WriteLine(); + + for (int i = 0; i < profile.Sync.Count; i++) + { + var syncItem = profile.Sync[i]; + allValid &= OutputSyncItemValidation(i + 1, syncItem, profile.Name); + } + } + + // Display available commands + var availableCommands = GetAvailableCommands(profile); if (availableCommands.Count != 0) { Console.WriteLine($"Available Commands: {string.Join(", ", availableCommands)}"); @@ -99,198 +91,193 @@ public Task OutputConfigList(CancellationToken cancellationToken = default) if (!xrmSyncSection.Exists()) { - Console.WriteLine("No XrmSync configurations found in appsettings.json"); + Console.WriteLine("No XrmSync configuration found in appsettings.json"); + return Task.CompletedTask; + } + + var profilesSection = xrmSyncSection.GetSection(XrmSyncConfigurationBuilder.SectionName.Profiles); + + if (!profilesSection.Exists()) + { + Console.WriteLine("No profiles found in XrmSync configuration"); return Task.CompletedTask; } - var configNames = xrmSyncSection.GetChildren() - .Select(c => c.Key) - .ToList(); + var profiles = profilesSection.GetChildren().ToList(); - if (configNames.Count == 0) + if (profiles.Count == 0) { - Console.WriteLine("No XrmSync configurations found in appsettings.json"); + Console.WriteLine("No profiles found in XrmSync configuration"); return Task.CompletedTask; } - Console.WriteLine($"Available configurations (from {GetConfigurationSource()}):"); + Console.WriteLine($"Available profiles (from {GetConfigurationSource()}):"); Console.WriteLine(); - foreach (var name in configNames) + foreach (var profileSection in profiles) { + var name = profileSection.GetValue("Name") ?? string.Empty; + var solutionName = profileSection.GetValue("SolutionName") ?? string.Empty; + var syncItems = profileSection.GetSection("Sync").GetChildren().ToList(); + Console.WriteLine($" - {name}"); + Console.WriteLine($" Solution: {solutionName}"); + + if (syncItems.Count > 0) + { + var syncTypes = syncItems + .Select(s => s.GetValue("Type")) + .Where(t => !string.IsNullOrEmpty(t)) + .ToList(); + Console.WriteLine($" Sync Items: {string.Join(", ", syncTypes)} ({syncItems.Count} total)"); + } + else + { + Console.WriteLine($" Sync Items: None"); + } - // Try to get a brief status for this config - var (isValid, summary) = GetConfigBriefStatus(name); - var statusSymbol = isValid ? "✓" : "✗"; - Console.WriteLine($" {statusSymbol} {summary}"); Console.WriteLine(); } return Task.CompletedTask; } - private bool OutputSectionValidation(string sectionName, ConfigurationScope scope, Action displayValues, string configSectionPath) + private bool OutputSyncItemValidation(int index, SyncItem syncItem, string profileName) { - if (configOptions == null || sharedOptions == null) - { - throw new InvalidOperationException("ConfigValidationOutput requires XrmSyncConfiguration and SharedOptions to validate configuration."); - } - - // Check if the section exists in the configuration - var configName = sharedOptions.Value.ConfigName; - var fullSectionPath = $"{XrmSyncConfigurationBuilder.SectionName.XrmSync}:{configName}:{configSectionPath}"; - var section = configuration.GetSection(fullSectionPath); - - // If section doesn't exist or has no children, it's not configured - if (!section.Exists() || !section.GetChildren().Any()) - { - Console.WriteLine($"⊘ {sectionName} (not configured)"); - Console.WriteLine(); - return true; // Not an error, just not configured - } + var itemLabel = $" [{index}] {syncItem.SyncType}"; try { - // Create a temporary validator to check this section - var validator = new XrmSyncConfigurationValidator(configOptions); - validator.Validate(scope); + var errors = syncItem switch + { + PluginSyncItem plugin => ValidatePluginSync(plugin), + PluginAnalysisSyncItem analysis => ValidatePluginAnalysis(analysis), + WebresourceSyncItem webresource => ValidateWebresource(webresource), + _ => new List { "Unknown sync item type" } + }; - // If we get here, validation passed - Console.WriteLine($"✓ {sectionName}"); - displayValues(); + if (errors.Count > 0) + { + Console.WriteLine($" ✗ {itemLabel}"); + DisplaySyncItemDetails(syncItem); + foreach (var error in errors) + { + Console.WriteLine($" Error: {error}"); + } + Console.WriteLine(); + return false; + } + + Console.WriteLine($" ✓ {itemLabel}"); + DisplaySyncItemDetails(syncItem); Console.WriteLine(); return true; } - catch (Model.Exceptions.OptionsValidationException ex) + catch (Exception ex) { - Console.WriteLine($"✗ {sectionName}"); - displayValues(); - Console.WriteLine($" {ex.Message}"); - Console.WriteLine(); - return false; - } - catch (AggregateException ex) - { - Console.WriteLine($"✗ {sectionName}"); - displayValues(); - Console.WriteLine($" Errors:"); - foreach (var innerEx in ex.InnerExceptions.OfType()) - { - Console.WriteLine($" {innerEx.Message}"); - } + Console.WriteLine($" ✗ {itemLabel}"); + Console.WriteLine($" Error: {ex.Message}"); Console.WriteLine(); return false; } } - private (bool isValid, string summary) GetConfigBriefStatus(string configName) + private void DisplaySyncItemDetails(SyncItem syncItem) { - try + switch (syncItem) { - // Create a temporary configuration builder for this config - var tempSharedOptions = Microsoft.Extensions.Options.Options.Create(new SharedOptions(false, null, configName)); - var tempBuilder = new XrmSyncConfigurationBuilder(configuration, tempSharedOptions); - var tempConfig = tempBuilder.Build(); - var tempConfigOptions = Microsoft.Extensions.Options.Options.Create(tempConfig); - var tempValidator = new XrmSyncConfigurationValidator(tempConfigOptions); - - var sections = new List(); - var hasErrors = false; - - // Helper to check if a section exists - bool SectionExists(string sectionPath) - { - var fullPath = $"{XrmSyncConfigurationBuilder.SectionName.XrmSync}:{configName}:{sectionPath}"; - var section = configuration.GetSection(fullPath); - return section.Exists() && section.GetChildren().Any(); - } - - // Check Plugin Sync section - if (SectionExists($"{XrmSyncConfigurationBuilder.SectionName.Plugin}:{XrmSyncConfigurationBuilder.SectionName.Sync}")) - { - try - { - tempValidator.Validate(ConfigurationScope.PluginSync); - sections.Add("plugins"); - } - catch { hasErrors = true; } - } - - // Check Plugin Analysis section - if (SectionExists($"{XrmSyncConfigurationBuilder.SectionName.Plugin}:{XrmSyncConfigurationBuilder.SectionName.Analysis}")) - { - try - { - tempValidator.Validate(ConfigurationScope.PluginAnalysis); - sections.Add("analyze"); - } - catch { hasErrors = true; } - } - - // Check Webresource Sync section - if (SectionExists($"{XrmSyncConfigurationBuilder.SectionName.Webresource}:{XrmSyncConfigurationBuilder.SectionName.Sync}")) - { - try - { - tempValidator.Validate(ConfigurationScope.WebresourceSync); - sections.Add("webresources"); - } - catch { hasErrors = true; } - } - - if (sections.Count == 0) - { - return (true, "No sections configured"); - } + case PluginSyncItem plugin: + Console.WriteLine($" Assembly Path: {plugin.AssemblyPath}"); + break; + case PluginAnalysisSyncItem analysis: + Console.WriteLine($" Assembly Path: {analysis.AssemblyPath}"); + Console.WriteLine($" Publisher Prefix: {analysis.PublisherPrefix}"); + Console.WriteLine($" Pretty Print: {analysis.PrettyPrint}"); + break; + case WebresourceSyncItem webresource: + Console.WriteLine($" Folder Path: {webresource.FolderPath}"); + break; + } + } - var summary = $"Configured: {string.Join(", ", sections)}"; - if (hasErrors) - { - summary += " (some sections have errors)"; - } + private List ValidatePluginSync(PluginSyncItem plugin) + { + var errors = new List(); - return (!hasErrors, summary); + if (string.IsNullOrWhiteSpace(plugin.AssemblyPath)) + { + errors.Add("Assembly path is required and cannot be empty."); } - catch (Exception ex) + else if (!File.Exists(Path.GetFullPath(plugin.AssemblyPath))) { - return (false, $"Error: {ex.Message}"); + errors.Add($"Assembly file does not exist: {plugin.AssemblyPath}"); } + else if (!Path.GetExtension(plugin.AssemblyPath).Equals(".dll", StringComparison.OrdinalIgnoreCase)) + { + errors.Add("Assembly file must have a .dll extension."); + } + + return errors; } - private List GetAvailableCommands() + private List ValidatePluginAnalysis(PluginAnalysisSyncItem analysis) { - if (configOptions == null) + var errors = ValidatePluginSync(new PluginSyncItem(analysis.AssemblyPath)); + + if (string.IsNullOrWhiteSpace(analysis.PublisherPrefix)) { - return new List(); + errors.Add("Publisher prefix is required and cannot be empty."); + } + else if (analysis.PublisherPrefix.Length < 2 || analysis.PublisherPrefix.Length > 8) + { + errors.Add("Publisher prefix must be between 2 and 8 characters."); + } + else if (!System.Text.RegularExpressions.Regex.IsMatch(analysis.PublisherPrefix, @"^[a-z][a-z0-9]{1,7}$")) + { + errors.Add("Publisher prefix must start with a lowercase letter and contain only lowercase letters and numbers."); } - var commands = new List(); - var validator = new XrmSyncConfigurationValidator(configOptions); + return errors; + } - // Check if plugin sync is configured - try + private List ValidateWebresource(WebresourceSyncItem webresource) + { + var errors = new List(); + + if (string.IsNullOrWhiteSpace(webresource.FolderPath)) { - validator.Validate(ConfigurationScope.PluginSync); - commands.Add("plugins"); + errors.Add("Webresource root path is required and cannot be empty."); } - catch { /* Not configured or invalid */ } - - // Check if plugin analysis is configured - try + else if (!Directory.Exists(Path.GetFullPath(webresource.FolderPath))) { - validator.Validate(ConfigurationScope.PluginAnalysis); - commands.Add("analyze"); + errors.Add($"Webresource root path does not exist: {webresource.FolderPath}"); } - catch { /* Not configured or invalid */ } - // Check if webresource sync is configured - try + return errors; + } + + private List GetAvailableCommands(ProfileConfiguration profile) + { + var commands = new List(); + + foreach (var syncItem in profile.Sync) { - validator.Validate(ConfigurationScope.WebresourceSync); - commands.Add("webresources"); + switch (syncItem) + { + case PluginSyncItem: + if (!commands.Contains("plugins")) + commands.Add("plugins"); + break; + case PluginAnalysisSyncItem: + if (!commands.Contains("analyze")) + commands.Add("analyze"); + break; + case WebresourceSyncItem: + if (!commands.Contains("webresources")) + commands.Add("webresources"); + break; + } } - catch { /* Not configured or invalid */ } return commands; } diff --git a/XrmSync/Options/ConfigWriter.cs b/XrmSync/Options/ConfigWriter.cs index 7666940..1ffb493 100644 --- a/XrmSync/Options/ConfigWriter.cs +++ b/XrmSync/Options/ConfigWriter.cs @@ -9,16 +9,16 @@ namespace XrmSync.Options; public interface IConfigWriter { - Task SaveConfig(string? filePath = null, string configName = XrmSyncConfigurationBuilder.DEFAULT_CONFIG_NAME, CancellationToken cancellationToken = default); + Task SaveConfig(string? filePath = null, CancellationToken cancellationToken = default); } internal class ConfigWriter(IOptions options, ILogger logger) : IConfigWriter { - public async Task SaveConfig(string? filePath = null, string configName = XrmSyncConfigurationBuilder.DEFAULT_CONFIG_NAME, CancellationToken cancellationToken = default) + public async Task SaveConfig(string? filePath = null, CancellationToken cancellationToken = default) { var targetFile = filePath ?? $"{ConfigReader.CONFIG_FILE_BASE}.json"; - logger.LogInformation("Saving configuration to {FilePath} with config name {ConfigName}", targetFile, configName); + logger.LogInformation("Saving configuration to {FilePath}", targetFile); var jsonOptions = new JsonSerializerOptions { @@ -35,7 +35,7 @@ public async Task SaveConfig(string? filePath = null, string configName = XrmSyn if (File.Exists(targetFile)) { var existingContent = await File.ReadAllTextAsync(targetFile, cancellationToken); - rootConfig = JsonSerializer.Deserialize>(existingContent, jsonOptions) + rootConfig = JsonSerializer.Deserialize>(existingContent, jsonOptions) ?? []; } else @@ -43,26 +43,9 @@ public async Task SaveConfig(string? filePath = null, string configName = XrmSyn rootConfig = []; } - // Get or create XrmSync section - if (!rootConfig.TryGetValue(XrmSyncConfigurationBuilder.SectionName.XrmSync, out var xrmSyncObj)) - { - xrmSyncObj = new Dictionary(); - rootConfig[XrmSyncConfigurationBuilder.SectionName.XrmSync] = xrmSyncObj; - } - - // Deserialize the XrmSync section to work with it - var xrmSyncJson = JsonSerializer.Serialize(xrmSyncObj, jsonOptions); - var xrmSyncSection = JsonSerializer.Deserialize>(xrmSyncJson, jsonOptions) - ?? []; - - // Serialize the configuration to a structure we can work with + // Serialize the configuration directly to XrmSync section var configJson = JsonSerializer.Serialize(options.Value, jsonOptions); - - // Save to appropriate location based on config name - // Named structure: save under XrmSync.{configName} - xrmSyncSection[configName] = JsonSerializer.Deserialize>(configJson, jsonOptions); - - rootConfig[XrmSyncConfigurationBuilder.SectionName.XrmSync] = xrmSyncSection; + rootConfig[XrmSyncConfigurationBuilder.SectionName.XrmSync] = JsonSerializer.Deserialize>(configJson, jsonOptions); // Serialize and save var json = JsonSerializer.Serialize(rootConfig, jsonOptions); diff --git a/XrmSync/Options/IConfigurationBuilder.cs b/XrmSync/Options/IConfigurationBuilder.cs index 62ee22d..24eb6b1 100644 --- a/XrmSync/Options/IConfigurationBuilder.cs +++ b/XrmSync/Options/IConfigurationBuilder.cs @@ -21,4 +21,5 @@ internal interface IConfigurationValidator internal interface IConfigurationBuilder { XrmSyncConfiguration Build(); + ProfileConfiguration? GetProfile(string? profileName); } \ No newline at end of file diff --git a/XrmSync/Options/XrmSyncConfigurationBuilder.cs b/XrmSync/Options/XrmSyncConfigurationBuilder.cs index 58826d6..84341b4 100644 --- a/XrmSync/Options/XrmSyncConfigurationBuilder.cs +++ b/XrmSync/Options/XrmSyncConfigurationBuilder.cs @@ -6,122 +6,129 @@ namespace XrmSync.Options; +#pragma warning disable CS9113 // Parameter is unread - false positive, used in GetProfile method internal class XrmSyncConfigurationBuilder(IConfiguration configuration, IOptions options) : IConfigurationBuilder +#pragma warning restore CS9113 { - internal const string DEFAULT_CONFIG_NAME = "default"; + internal const string DEFAULT_PROFILE_NAME = "default"; public static class SectionName { public const string XrmSync = nameof(XrmSync); - public const string Plugin = nameof(Plugin); - public const string Webresource = nameof(Webresource); - public const string Sync = nameof(Sync); - public const string Analysis = nameof(Analysis); - public const string Logger = nameof(Logger); - public const string Execution = nameof(Execution); + public const string Profiles = nameof(Profiles); + public const string DryRun = nameof(DryRun); + public const string LogLevel = nameof(LogLevel); + public const string CiMode = nameof(CiMode); } public XrmSyncConfiguration Build() { + var xrmSyncSection = configuration.GetSection(SectionName.XrmSync); + return new XrmSyncConfiguration( - new( - BuildPluginSyncOptions(), - BuildAnalysisOptions() - ), - new( - BuildWebresourceSyncOptions() - ), - BuildLoggerOptions(), - BuildExecutionOptions() + xrmSyncSection.GetValue(SectionName.DryRun), + xrmSyncSection.GetValue(SectionName.LogLevel) ?? Microsoft.Extensions.Logging.LogLevel.Information, + xrmSyncSection.GetValue(SectionName.CiMode), + BuildProfiles(xrmSyncSection) ); } - private PluginSyncOptions BuildPluginSyncOptions() + private List BuildProfiles(IConfigurationSection xrmSyncSection) { - var pluginSyncSection = GetConfigurationSection($"{SectionName.Plugin}:{SectionName.Sync}"); - return new PluginSyncOptions( - pluginSyncSection.GetValue(nameof(PluginSyncOptions.AssemblyPath)) ?? string.Empty, - pluginSyncSection.GetValue(nameof(PluginSyncOptions.SolutionName)) ?? string.Empty - ); - } + var profilesSection = xrmSyncSection.GetSection(SectionName.Profiles); - private WebresourceSyncOptions BuildWebresourceSyncOptions() - { - var webresourceSyncSection = GetConfigurationSection($"{SectionName.Webresource}:{SectionName.Sync}"); - return new WebresourceSyncOptions( - webresourceSyncSection.GetValue(nameof(WebresourceSyncOptions.FolderPath)) ?? string.Empty, - webresourceSyncSection.GetValue(nameof(WebresourceSyncOptions.SolutionName)) ?? string.Empty - ); - } + if (!profilesSection.Exists()) + { + return new List(); + } - private PluginAnalysisOptions BuildAnalysisOptions() - { - var analysisSection = GetConfigurationSection($"{SectionName.Plugin}:{SectionName.Analysis}"); - return new ( - analysisSection.GetValue(nameof(PluginAnalysisOptions.AssemblyPath)) ?? string.Empty, - analysisSection.GetValue(nameof(PluginAnalysisOptions.PublisherPrefix)) ?? "new", - analysisSection.GetValue(nameof(PluginAnalysisOptions.PrettyPrint)) - ); - } + var profiles = new List(); - private LoggerOptions BuildLoggerOptions() - { - var loggerSection = GetConfigurationSection(SectionName.Logger); - return new ( - loggerSection.GetValue(nameof(LoggerOptions.LogLevel)) ?? LogLevel.Information, - loggerSection.GetValue(nameof(LoggerOptions.CiMode)) - ); + foreach (var profileSection in profilesSection.GetChildren()) + { + var name = profileSection.GetValue(nameof(ProfileConfiguration.Name)) ?? string.Empty; + var solutionName = profileSection.GetValue(nameof(ProfileConfiguration.SolutionName)) ?? string.Empty; + var syncItems = BuildSyncItems(profileSection.GetSection(nameof(ProfileConfiguration.Sync))); + + profiles.Add(new ProfileConfiguration(name, solutionName, syncItems)); + } + + return profiles; } - private ExecutionOptions BuildExecutionOptions() + private List BuildSyncItems(IConfigurationSection syncSection) { - var executionSection = GetConfigurationSection(SectionName.Execution); - return new ( - executionSection.GetValue(nameof(ExecutionOptions.DryRun)) - ); + var syncItems = new List(); + + if (!syncSection.Exists()) + { + return syncItems; + } + + foreach (var itemSection in syncSection.GetChildren()) + { + var type = itemSection.GetValue("Type") ?? string.Empty; + + SyncItem? syncItem = type switch + { + "Plugin" => new PluginSyncItem( + itemSection.GetValue(nameof(PluginSyncItem.AssemblyPath)) ?? string.Empty + ), + "PluginAnalysis" => new PluginAnalysisSyncItem( + itemSection.GetValue(nameof(PluginAnalysisSyncItem.AssemblyPath)) ?? string.Empty, + itemSection.GetValue(nameof(PluginAnalysisSyncItem.PublisherPrefix)) ?? "new", + itemSection.GetValue(nameof(PluginAnalysisSyncItem.PrettyPrint)) + ), + "Webresource" => new WebresourceSyncItem( + itemSection.GetValue(nameof(WebresourceSyncItem.FolderPath)) ?? string.Empty + ), + _ => null + }; + + if (syncItem != null) + { + syncItems.Add(syncItem); + } + } + + return syncItems; } - private IConfigurationSection GetConfigurationSection(string sectionPath) + public ProfileConfiguration? GetProfile(string? profileName) { - // If a config name is specified, use named configuration - var resolvedConfigName = ResolveConfigurationName(options.Value.ConfigName); - - return configuration.GetSection($"{SectionName.XrmSync}:{resolvedConfigName}:{sectionPath}"); + var config = Build(); + var resolvedProfileName = ResolveProfileName(profileName, config.Profiles); + + return config.Profiles.FirstOrDefault(p => p.Name.Equals(resolvedProfileName, StringComparison.OrdinalIgnoreCase)); } - private string ResolveConfigurationName(string requestedName) + private string ResolveProfileName(string? requestedName, List profiles) { - var xrmSyncSection = configuration.GetSection(SectionName.XrmSync); - - if (!xrmSyncSection.Exists()) + if (profiles.Count == 0) { - return DEFAULT_CONFIG_NAME; + return DEFAULT_PROFILE_NAME; } - // Get all configuration names (direct children of XrmSync) - var configNames = xrmSyncSection.GetChildren() - .Select(c => c.Key) - .ToList(); - // If requested name exists, use it - if (configNames.Contains(requestedName)) + if (!string.IsNullOrWhiteSpace(requestedName) && profiles.Any(p => p.Name.Equals(requestedName, StringComparison.OrdinalIgnoreCase))) { return requestedName; } - // If only one named config exists, use it - if (configNames.Count == 1) + // If only one profile exists, use it + if (profiles.Count == 1) { - return configNames[0]; + return profiles[0].Name; } - // If multiple configs exist, try to use "default" - if (configNames.Contains(DEFAULT_CONFIG_NAME)) + // If multiple profiles exist, try to use "default" + var defaultProfile = profiles.FirstOrDefault(p => p.Name.Equals(DEFAULT_PROFILE_NAME, StringComparison.OrdinalIgnoreCase)); + if (defaultProfile != null) { - return DEFAULT_CONFIG_NAME; + return defaultProfile.Name; } - // Throw exception if we cannot resolve the configuration - throw new XrmSyncException($"Multiple configuration sections found under '{SectionName.XrmSync}', but no {DEFAULT_CONFIG_NAME} configuration section found, or name wasn't specified."); + // Throw exception if we cannot resolve the profile + throw new XrmSyncException($"Multiple profiles found, but no '{DEFAULT_PROFILE_NAME}' profile found, and no profile name was specified. Use --profile to specify which profile to use."); } } diff --git a/XrmSync/Options/XrmSyncConfigurationValidator.cs b/XrmSync/Options/XrmSyncConfigurationValidator.cs index 56a8d28..07c0a49 100644 --- a/XrmSync/Options/XrmSyncConfigurationValidator.cs +++ b/XrmSync/Options/XrmSyncConfigurationValidator.cs @@ -4,7 +4,7 @@ namespace XrmSync.Options; -internal partial class XrmSyncConfigurationValidator(IOptions configuration) : IConfigurationValidator +internal partial class XrmSyncConfigurationValidator(IOptions configuration, IOptions sharedOptions) : IConfigurationValidator { public void Validate(ConfigurationScope scope) { @@ -13,7 +13,7 @@ public void Validate(ConfigurationScope scope) throw new Model.Exceptions.OptionsValidationException("No configuration scope specified for validation."); } - var exceptions = ValidateInternal(scope, configuration.Value).ToList(); + var exceptions = ValidateInternal(scope, configuration.Value, sharedOptions.Value.ProfileName).ToList(); if (exceptions.Count == 1) { throw exceptions[0]; @@ -23,60 +23,84 @@ public void Validate(ConfigurationScope scope) } } - private static IEnumerable ValidateInternal(ConfigurationScope scope, XrmSyncConfiguration configuration) + private static IEnumerable ValidateInternal(ConfigurationScope scope, XrmSyncConfiguration configuration, string profileName) { - if (scope.HasFlag(ConfigurationScope.PluginSync)) - { - var errors = Validate(configuration.Plugin.Sync).ToList(); + // Find the profile to validate + var profile = configuration.Profiles.FirstOrDefault(p => p.Name.Equals(profileName, StringComparison.OrdinalIgnoreCase)); - if (errors.Count != 0) + if (profile == null) + { + // If a specific profile name was requested but not found, throw an error + if (!string.IsNullOrWhiteSpace(profileName)) { - yield return new Model.Exceptions.OptionsValidationException("Plugin sync", errors); + yield return new Model.Exceptions.OptionsValidationException("Profile", new[] { $"Profile '{profileName}' not found in configuration." }); + yield break; } + + // No profiles configured and no specific profile requested, validation passes (CLI mode) + yield break; } - if (scope.HasFlag(ConfigurationScope.PluginAnalysis)) + // Validate solution name at profile level + var profileErrors = ValidateSolutionName(profile.SolutionName).ToList(); + if (profileErrors.Count != 0) { - var errors = Validate(configuration.Plugin.Analysis).ToList(); - - if (errors.Count != 0) - { - yield return new Model.Exceptions.OptionsValidationException("Plugin analysis", errors); - } + yield return new Model.Exceptions.OptionsValidationException($"Profile '{profile.Name}'", profileErrors); } - if (scope.HasFlag(ConfigurationScope.WebresourceSync)) + // Validate each sync item in the profile based on scope + foreach (var syncItem in profile.Sync) { - var errors = Validate(configuration.Webresource.Sync).ToList(); + List errors = new(); - if (errors.Count != 0) + switch (syncItem) { - yield return new Model.Exceptions.OptionsValidationException("Webresource sync", errors); + case PluginSyncItem pluginSync when scope.HasFlag(ConfigurationScope.PluginSync): + errors = Validate(pluginSync).ToList(); + if (errors.Count != 0) + { + yield return new Model.Exceptions.OptionsValidationException($"Plugin sync in profile '{profile.Name}'", errors); + } + break; + + case PluginAnalysisSyncItem pluginAnalysis when scope.HasFlag(ConfigurationScope.PluginAnalysis): + errors = Validate(pluginAnalysis).ToList(); + if (errors.Count != 0) + { + yield return new Model.Exceptions.OptionsValidationException($"Plugin analysis in profile '{profile.Name}'", errors); + } + break; + + case WebresourceSyncItem webresource when scope.HasFlag(ConfigurationScope.WebresourceSync): + errors = Validate(webresource).ToList(); + if (errors.Count != 0) + { + yield return new Model.Exceptions.OptionsValidationException($"Webresource sync in profile '{profile.Name}'", errors); + } + break; } } } - private static IEnumerable Validate(PluginSyncOptions options) + private static IEnumerable Validate(PluginSyncItem syncItem) { return [ - ..ValidateAssemblyPath(options.AssemblyPath), - ..ValidateSolutionName(options.SolutionName) + ..ValidateAssemblyPath(syncItem.AssemblyPath) ]; } - private static IEnumerable Validate(PluginAnalysisOptions options) + private static IEnumerable Validate(PluginAnalysisSyncItem syncItem) { return [ - ..ValidateAssemblyPath(options.AssemblyPath), - ..ValidatePublisherPrefix(options.PublisherPrefix) + ..ValidateAssemblyPath(syncItem.AssemblyPath), + ..ValidatePublisherPrefix(syncItem.PublisherPrefix) ]; } - private static IEnumerable Validate(WebresourceSyncOptions options) + private static IEnumerable Validate(WebresourceSyncItem syncItem) { return [ - ..ValidateFolderPath(options.FolderPath), - ..ValidateSolutionName(options.SolutionName) + ..ValidateFolderPath(syncItem.FolderPath) ]; } From 212dbeeb1b78b03e8dbb9314311780df82eb4f0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Gether=20S=C3=B8rensen=20=28Delegate=29?= Date: Fri, 14 Nov 2025 15:14:57 +0100 Subject: [PATCH 2/7] Default profile if only one instead of magic profile named default --- AssemblyAnalyzer/Reader/LocalReader.cs | 7 ++--- Model/XrmSyncOptions.cs | 4 +-- README.md | 24 ++++++++--------- Tests/Config/NamedConfigurationTests.cs | 27 ++++++++----------- XrmSync/Commands/XrmSyncCommandBase.cs | 2 +- XrmSync/Commands/XrmSyncRootCommand.cs | 27 ++++++++++++++----- XrmSync/Constants/CliOptions.cs | 2 +- .../Options/XrmSyncConfigurationBuilder.cs | 18 ++++--------- .../Options/XrmSyncConfigurationValidator.cs | 2 +- 9 files changed, 57 insertions(+), 56 deletions(-) diff --git a/AssemblyAnalyzer/Reader/LocalReader.cs b/AssemblyAnalyzer/Reader/LocalReader.cs index 68b8a36..060391a 100644 --- a/AssemblyAnalyzer/Reader/LocalReader.cs +++ b/AssemblyAnalyzer/Reader/LocalReader.cs @@ -78,7 +78,7 @@ public List ReadWebResourceFolder(string folderPath, stri ]; } - private async Task ReadAssemblyInternalAsync(string assemblyDllPath, string publisherPrefix, string configName, CancellationToken cancellationToken) + private async Task ReadAssemblyInternalAsync(string assemblyDllPath, string publisherPrefix, string? configName, CancellationToken cancellationToken) { var (filename, args) = await GetExecutionInfoAsync(assemblyDllPath, publisherPrefix, configName, cancellationToken); @@ -97,9 +97,10 @@ private async Task ReadAssemblyInternalAsync(string assemblyDllPat return assemblyInfo ?? throw new AnalysisException("Failed to read plugin type information from assembly"); } - private async Task<(string filename, string args)> GetExecutionInfoAsync(string assemblyDllPath, string publisherPrefix, string configName, CancellationToken cancellationToken) + private async Task<(string filename, string args)> GetExecutionInfoAsync(string assemblyDllPath, string publisherPrefix, string? configName, CancellationToken cancellationToken) { - var baseArgs = $"analyze --assembly \"{assemblyDllPath}\" --prefix \"{publisherPrefix}\" --config \"{configName}\""; + var configArg = !string.IsNullOrWhiteSpace(configName) ? $" --profile \"{configName}\"" : ""; + var baseArgs = $"analyze --assembly \"{assemblyDllPath}\" --prefix \"{publisherPrefix}\"{configArg}"; #if DEBUG // In debug, try to invoke the currently executing assembly first diff --git a/Model/XrmSyncOptions.cs b/Model/XrmSyncOptions.cs index b71b1cd..6b0aa7f 100644 --- a/Model/XrmSyncOptions.cs +++ b/Model/XrmSyncOptions.cs @@ -50,9 +50,9 @@ public record WebresourceSyncItem(string FolderPath) : SyncItem public override string SyncType => "Webresource"; } -public record SharedOptions(bool SaveConfig, string? SaveConfigTo, string ProfileName) +public record SharedOptions(bool SaveConfig, string? SaveConfigTo, string? ProfileName) { - public static SharedOptions Empty => new(false, null, string.Empty); + public static SharedOptions Empty => new(false, null, null); } // Command-specific options that can be populated from CLI or profile diff --git a/README.md b/README.md index 3e02e93..5b90a81 100644 --- a/README.md +++ b/README.md @@ -76,10 +76,11 @@ xrmsync webresources --folder "path/to/webresources" --solution-name "YourSoluti For repeated operations or complex configurations, you can read the configuration from the appsettings.json file: ```bash # Run all configured commands (plugins, webresources, analysis) -xrmsync --profile default +# If only one profile exists, it's used automatically without --profile +xrmsync --profile myprofile # Run a specific command with configuration -xrmsync plugins --profile default +xrmsync plugins --profile myprofile ``` You can also override specific options when using a configuration file: @@ -150,7 +151,7 @@ The webresource name in Dataverse is determined by the file path relative to the | Option | Short | Description | Required | |--------|-------|-------------|----------| -| `--profile` | `-p`, `--profile-name` | Name of the profile to validate | No (Default: "default") | +| `--profile` | `-p`, `--profile-name` | Name of the profile to validate | No (auto-selects if only one profile exists) | **Config List Command** @@ -306,14 +307,11 @@ XrmSync supports multiple named configurations within a single appsettings.json **Using named configurations:** ```bash -# Use the 'default' configuration (or the only configuration if only one exists) -xrmsync --profile default - -# Use a specific named configuration -xrmsync --profile dev - -# If --config is not specified, 'default' is used, or the single config if only one exists +# If only one profile exists, it's used automatically xrmsync + +# If multiple profiles exist, you must specify which one to use +xrmsync --profile ``` **Example with multiple named configurations:** @@ -504,10 +502,10 @@ xrmsync webresources --folder "wwwroot" --solution-name "MyCustomSolution" --dry #### Using a configuration file to run all configured commands: ```bash # Runs all configured sub-commands (plugin sync, analysis, webresource sync) -# from the 'default' configuration -xrmsync --profile default +# from the specified profile +xrmsync --profile myprofile -# Or simply (uses 'default' if it exists, or the only config if there's just one) +# If only one profile exists, it's used automatically xrmsync ``` diff --git a/Tests/Config/NamedConfigurationTests.cs b/Tests/Config/NamedConfigurationTests.cs index bdc44ea..edd54e7 100644 --- a/Tests/Config/NamedConfigurationTests.cs +++ b/Tests/Config/NamedConfigurationTests.cs @@ -70,7 +70,7 @@ public void ResolveConfigurationName_WithSpecificName_ReturnsRequestedName() } [Fact] - public void ResolveConfigurationName_WithNoSpecificName_ReturnsDefault() + public void ResolveConfigurationName_WithMultipleProfilesAndNoSpecificName_ThrowsException() { // Arrange const string configJson = """ @@ -81,22 +81,22 @@ public void ResolveConfigurationName_WithNoSpecificName_ReturnsDefault() "CiMode": false, "Profiles": [ { - "Name": "default", - "SolutionName": "DefaultSolution", + "Name": "profile1", + "SolutionName": "Solution1", "Sync": [ { "Type": "Plugin", - "AssemblyPath": "default.dll" + "AssemblyPath": "profile1.dll" } ] }, { - "Name": "dev", - "SolutionName": "DevSolution", + "Name": "profile2", + "SolutionName": "Solution2", "Sync": [ { "Type": "Plugin", - "AssemblyPath": "dev.dll" + "AssemblyPath": "profile2.dll" } ] } @@ -113,15 +113,10 @@ public void ResolveConfigurationName_WithNoSpecificName_ReturnsDefault() var configReader = new TestConfigReader(tempFile); var builder = new XrmSyncConfigurationBuilder(configReader.GetConfiguration(), Options.Create(SharedOptions.Empty)); - // Act - var profile = builder.GetProfile(null); - - // Assert - Assert.NotNull(profile); - Assert.Equal("default", profile.Name); - Assert.Single(profile.Sync); - var pluginSync = Assert.IsType(profile.Sync[0]); - Assert.Equal("default.dll", pluginSync.AssemblyPath); + // Act & Assert + var exception = Assert.Throws(() => builder.GetProfile(null)); + Assert.Contains("Multiple profiles found", exception.Message); + Assert.Contains("--profile", exception.Message); } finally { diff --git a/XrmSync/Commands/XrmSyncCommandBase.cs b/XrmSync/Commands/XrmSyncCommandBase.cs index 0159699..5d65fba 100644 --- a/XrmSync/Commands/XrmSyncCommandBase.cs +++ b/XrmSync/Commands/XrmSyncCommandBase.cs @@ -57,7 +57,7 @@ protected SharedOptions GetSharedOptionValues(ParseResult parseResult) { var saveConfig = parseResult.GetValue(SaveConfigOption); var saveConfigTo = saveConfig ? parseResult.GetValue(SaveConfigToOption) ?? ConfigReader.CONFIG_FILE_BASE + ".json" : null; - var profileName = parseResult.GetValue(ProfileNameOption) ?? XrmSyncConfigurationBuilder.DEFAULT_PROFILE_NAME; + var profileName = parseResult.GetValue(ProfileNameOption); return new (saveConfig, saveConfigTo, profileName); } diff --git a/XrmSync/Commands/XrmSyncRootCommand.cs b/XrmSync/Commands/XrmSyncRootCommand.cs index 8827872..508464a 100644 --- a/XrmSync/Commands/XrmSyncRootCommand.cs +++ b/XrmSync/Commands/XrmSyncRootCommand.cs @@ -134,10 +134,15 @@ private async Task ExecutePluginSync( var args = new List { CliOptions.Assembly.Primary, syncItem.AssemblyPath, - CliOptions.Solution.Primary, profile.SolutionName, - CliOptions.Config.LoadConfig.Primary, sharedOptions.ProfileName + CliOptions.Solution.Primary, profile.SolutionName }; + if (!string.IsNullOrWhiteSpace(sharedOptions.ProfileName)) + { + args.Add(CliOptions.Config.LoadConfig.Primary); + args.Add(sharedOptions.ProfileName); + } + AddCommonArgs(args, overrides, config); return await ExecuteSubCommand("plugins", [.. args]); } @@ -151,10 +156,15 @@ private async Task ExecutePluginAnalysis( var args = new List { CliOptions.Assembly.Primary, syncItem.AssemblyPath, - CliOptions.Analysis.Prefix.Primary, syncItem.PublisherPrefix, - CliOptions.Config.LoadConfig.Primary, sharedOptions.ProfileName + CliOptions.Analysis.Prefix.Primary, syncItem.PublisherPrefix }; + if (!string.IsNullOrWhiteSpace(sharedOptions.ProfileName)) + { + args.Add(CliOptions.Config.LoadConfig.Primary); + args.Add(sharedOptions.ProfileName); + } + if (syncItem.PrettyPrint) args.Add(CliOptions.Analysis.PrettyPrint.Primary); @@ -172,10 +182,15 @@ private async Task ExecuteWebresourceSync( var args = new List { CliOptions.Webresource.Primary, syncItem.FolderPath, - CliOptions.Solution.Primary, profile.SolutionName, - CliOptions.Config.LoadConfig.Primary, sharedOptions.ProfileName + CliOptions.Solution.Primary, profile.SolutionName }; + if (!string.IsNullOrWhiteSpace(sharedOptions.ProfileName)) + { + args.Add(CliOptions.Config.LoadConfig.Primary); + args.Add(sharedOptions.ProfileName); + } + AddCommonArgs(args, overrides, config); return await ExecuteSubCommand("webresources", [.. args]); } diff --git a/XrmSync/Constants/CliOptions.cs b/XrmSync/Constants/CliOptions.cs index e080d45..fa76353 100644 --- a/XrmSync/Constants/CliOptions.cs +++ b/XrmSync/Constants/CliOptions.cs @@ -91,7 +91,7 @@ public static class LoadConfig { public const string Primary = "--profile"; public static readonly string[] Aliases = ["--profile-name", "-p"]; - public const string Description = "Name of the profile to load from appsettings.json (Default: 'default' or single profile if only one exists)"; + public const string Description = "Name of the profile to load from appsettings.json (automatically uses single profile if only one exists)"; } } diff --git a/XrmSync/Options/XrmSyncConfigurationBuilder.cs b/XrmSync/Options/XrmSyncConfigurationBuilder.cs index 84341b4..f00d2bd 100644 --- a/XrmSync/Options/XrmSyncConfigurationBuilder.cs +++ b/XrmSync/Options/XrmSyncConfigurationBuilder.cs @@ -10,7 +10,6 @@ namespace XrmSync.Options; internal class XrmSyncConfigurationBuilder(IConfiguration configuration, IOptions options) : IConfigurationBuilder #pragma warning restore CS9113 { - internal const string DEFAULT_PROFILE_NAME = "default"; public static class SectionName { @@ -102,11 +101,11 @@ private List BuildSyncItems(IConfigurationSection syncSection) return config.Profiles.FirstOrDefault(p => p.Name.Equals(resolvedProfileName, StringComparison.OrdinalIgnoreCase)); } - private string ResolveProfileName(string? requestedName, List profiles) + private string? ResolveProfileName(string? requestedName, List profiles) { if (profiles.Count == 0) { - return DEFAULT_PROFILE_NAME; + return null; } // If requested name exists, use it @@ -115,20 +114,13 @@ private string ResolveProfileName(string? requestedName, List p.Name.Equals(DEFAULT_PROFILE_NAME, StringComparison.OrdinalIgnoreCase)); - if (defaultProfile != null) - { - return defaultProfile.Name; - } - - // Throw exception if we cannot resolve the profile - throw new XrmSyncException($"Multiple profiles found, but no '{DEFAULT_PROFILE_NAME}' profile found, and no profile name was specified. Use --profile to specify which profile to use."); + // If multiple profiles exist and no profile specified, require explicit selection + throw new XrmSyncException($"Multiple profiles found. Use --profile to specify which profile to use, or run 'xrmsync config list' to see available profiles."); } } diff --git a/XrmSync/Options/XrmSyncConfigurationValidator.cs b/XrmSync/Options/XrmSyncConfigurationValidator.cs index 07c0a49..5aae29a 100644 --- a/XrmSync/Options/XrmSyncConfigurationValidator.cs +++ b/XrmSync/Options/XrmSyncConfigurationValidator.cs @@ -23,7 +23,7 @@ public void Validate(ConfigurationScope scope) } } - private static IEnumerable ValidateInternal(ConfigurationScope scope, XrmSyncConfiguration configuration, string profileName) + private static IEnumerable ValidateInternal(ConfigurationScope scope, XrmSyncConfiguration configuration, string? profileName) { // Find the profile to validate var profile = configuration.Profiles.FirstOrDefault(p => p.Name.Equals(profileName, StringComparison.OrdinalIgnoreCase)); From ddf49477fc14127224c28d068512afa7d49f56aa Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Tue, 18 Nov 2025 10:35:19 +0100 Subject: [PATCH 3/7] CHORE: Update README to reflect new config format --- README.md | 243 +++++++++++++++++++++++++++++------------------------- 1 file changed, 132 insertions(+), 111 deletions(-) diff --git a/README.md b/README.md index 5b90a81..31e3481 100644 --- a/README.md +++ b/README.md @@ -239,7 +239,7 @@ Available configurations (from appsettings.json): XrmSync supports JSON configuration files that contain all the necessary settings for synchronization and analysis. This is particularly useful for CI/CD pipelines or when you have consistent settings across multiple runs. -The configuration uses a hierarchical structure under the XrmSync section, separating sync and analysis options under Plugin.Sync and Plugin.Analysis respectively. +The configuration uses a profile-based structure under the XrmSync section, with global settings (DryRun, LogLevel, CiMode) and an array of named profiles. Each profile contains a solution name and a list of sync items (Plugin, Webresource, PluginAnalysis). #### Generating Configuration Files @@ -271,32 +271,31 @@ When using `--save-config`, XrmSync will: ```json { "XrmSync": { - "default": { - "Plugin": { - "Sync": { - "AssemblyPath": "path/to/your/plugin.dll", - "SolutionName": "YourSolutionName" - }, - "Analysis": { - "AssemblyPath": "path/to/your/plugin.dll", - "PublisherPrefix": "contoso", - "PrettyPrint": true - } - }, - "Webresource": { - "Sync": { - "FolderPath": "path/to/webresources", - "SolutionName": "YourSolutionName" - } - }, - "Logger": { - "LogLevel": "Information", - "CiMode": false - }, - "Execution": { - "DryRun": false + "DryRun": false, + "LogLevel": "Information", + "CiMode": false, + "Profiles": [ + { + "Name": "default", + "SolutionName": "YourSolutionName", + "Sync": [ + { + "Type": "Plugin", + "AssemblyPath": "path/to/your/plugin.dll" + }, + { + "Type": "Webresource", + "FolderPath": "path/to/webresources" + }, + { + "Type": "PluginAnalysis", + "AssemblyPath": "path/to/your/plugin.dll", + "PublisherPrefix": "contoso", + "PrettyPrint": true + } + ] } - } + ] } } ``` @@ -318,28 +317,31 @@ xrmsync --profile ```json { "XrmSync": { - "default": { - "Plugin": { - "Sync": { - "AssemblyPath": "bin/Debug/net462/MyPlugin.dll", - "SolutionName": "DevSolution" - } - }, - "Execution": { - "DryRun": true - } - }, - "prod": { - "Plugin": { - "Sync": { - "AssemblyPath": "bin/Release/net462/MyPlugin.dll", - "SolutionName": "ProdSolution" - } + "DryRun": false, + "LogLevel": "Information", + "CiMode": false, + "Profiles": [ + { + "Name": "default", + "SolutionName": "DevSolution", + "Sync": [ + { + "Type": "Plugin", + "AssemblyPath": "bin/Debug/net462/MyPlugin.dll" + } + ] }, - "Execution": { - "DryRun": false + { + "Name": "prod", + "SolutionName": "ProdSolution", + "Sync": [ + { + "Type": "Plugin", + "AssemblyPath": "bin/Release/net462/MyPlugin.dll" + } + ] } - } + ] } } ``` @@ -359,40 +361,48 @@ XrmSync will only execute sub-commands that have their required properties confi - Plugin analysis runs only if `AssemblyPath` is provided - Webresource sync runs only if `FolderPath` and `SolutionName` are provided -#### Plugin Sync Properties +#### Global Properties | Property | Type | Description | Default | |----------|------|-------------|---------| -| `AssemblyPath` | string | Path to the plugin assembly (*.dll) | Required | -| `SolutionName` | string | Name of the target Dataverse solution | Required | +| `DryRun` | boolean | Perform a dry run without making changes | false | +| `LogLevel` | string | Log level (Trace, Debug, Information, Warning, Error, Critical) | "Information" | +| `CiMode` | boolean | Enable CI mode for easier parsing in CI systems | false | -#### Plugin Analysis Properties +#### Profile Properties | Property | Type | Description | Default | |----------|------|-------------|---------| -| `AssemblyPath` | string | Path to the plugin assembly (*.dll) | Required | -| `PublisherPrefix` | string | Publisher prefix for unique names | "new" | -| `PrettyPrint` | boolean | Pretty print the JSON output | false | +| `Name` | string | Name of the profile | Required | +| `SolutionName` | string | Name of the target Dataverse solution | Required | +| `Sync` | array | List of sync items (see below) | Required | + +#### Sync Item Properties -#### Webresource Sync Properties +Each sync item must have a `Type` property indicating the sync type: + +**Plugin Sync Item (Type: "Plugin")** | Property | Type | Description | Default | |----------|------|-------------|---------| -| `FolderPath` | string | Path to the root folder containing webresources | Required | -| `SolutionName` | string | Name of the target Dataverse solution | Required | +| `Type` | string | Must be "Plugin" | Required | +| `AssemblyPath` | string | Path to the plugin assembly (*.dll) | Required | -#### Logger Properties +**Webresource Sync Item (Type: "Webresource")** | Property | Type | Description | Default | |----------|------|-------------|---------| -| `LogLevel` | string | Log level (Trace, Debug, Information, Warning, Error, Critical) | "Information" | -| `CiMode` | boolean | Enable CI mode for easier parsing in CI systems | false | +| `Type` | string | Must be "Webresource" | Required | +| `FolderPath` | string | Path to the root folder containing webresources | Required | -#### Execution Properties +**Plugin Analysis Item (Type: "PluginAnalysis")** | Property | Type | Description | Default | |----------|------|-------------|---------| -| `DryRun` | boolean | Perform a dry run without making changes | false | +| `Type` | string | Must be "PluginAnalysis" | Required | +| `AssemblyPath` | string | Path to the plugin assembly (*.dll) | Required | +| `PublisherPrefix` | string | Publisher prefix for unique names | "new" | +| `PrettyPrint` | boolean | Pretty print the JSON output | false | #### Example Configuration Files @@ -400,14 +410,18 @@ XrmSync will only execute sub-commands that have their required properties confi ```json { "XrmSync": { - "default": { - "Plugin": { - "Sync": { - "AssemblyPath": "MyPlugin.dll", - "SolutionName": "MyCustomSolution" - } + "Profiles": [ + { + "Name": "default", + "SolutionName": "MyCustomSolution", + "Sync": [ + { + "Type": "Plugin", + "AssemblyPath": "MyPlugin.dll" + } + ] } - } + ] } } ``` @@ -417,31 +431,31 @@ XrmSync will only execute sub-commands that have their required properties confi ```json { "XrmSync": { - "default": { - "Plugin": { - "Sync": { - "AssemblyPath": "bin/Release/net462/MyPlugin.dll", - "SolutionName": "MyCustomSolution" - }, - "Analysis": { - "AssemblyPath": "bin/Release/net462/MyPlugin.dll", - "PublisherPrefix": "contoso", - "PrettyPrint": true - } - }, - "Webresource": { - "Sync": { - "FolderPath": "wwwroot", - "SolutionName": "MyCustomSolution" - } - }, - "Logger": { - "LogLevel": "Debug" - }, - "Execution": { - "DryRun": true + "DryRun": true, + "LogLevel": "Debug", + "CiMode": false, + "Profiles": [ + { + "Name": "default", + "SolutionName": "MyCustomSolution", + "Sync": [ + { + "Type": "Plugin", + "AssemblyPath": "bin/Release/net462/MyPlugin.dll" + }, + { + "Type": "Webresource", + "FolderPath": "wwwroot" + }, + { + "Type": "PluginAnalysis", + "AssemblyPath": "bin/Release/net462/MyPlugin.dll", + "PublisherPrefix": "contoso", + "PrettyPrint": true + } + ] } - } + ] } } ``` @@ -450,17 +464,19 @@ XrmSync will only execute sub-commands that have their required properties confi ```json { "XrmSync": { - "default": { - "Webresource": { - "Sync": { - "FolderPath": "src/webresources", - "SolutionName": "MyCustomSolution" - } - }, - "Execution": { - "DryRun": false + "DryRun": false, + "Profiles": [ + { + "Name": "default", + "SolutionName": "MyCustomSolution", + "Sync": [ + { + "Type": "Webresource", + "FolderPath": "src/webresources" + } + ] } - } + ] } } ``` @@ -469,15 +485,20 @@ XrmSync will only execute sub-commands that have their required properties confi ```json { "XrmSync": { - "default": { - "Plugin": { - "Analysis": { - "AssemblyPath": "../../../bin/Debug/net462/ILMerged.SamplePlugins.dll", - "PublisherPrefix": "contoso", - "PrettyPrint": false - } + "Profiles": [ + { + "Name": "default", + "SolutionName": "MySolution", + "Sync": [ + { + "Type": "PluginAnalysis", + "AssemblyPath": "../../../bin/Debug/net462/ILMerged.SamplePlugins.dll", + "PublisherPrefix": "contoso", + "PrettyPrint": false + } + ] } - } + ] } } ``` @@ -669,7 +690,7 @@ The solution consists of several projects: ### Prerequisites -- .NET 8 SDK +- .NET 10 SDK - Visual Studio 2022 or VS Code - Access to a Dataverse environment for testing From d8680f944b3f985cd06557df554c59ffa2e459e1 Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Tue, 18 Nov 2025 11:33:05 +0100 Subject: [PATCH 4/7] Remove the save-config functionality, it add unnessary complexity --- CHANGELOG.md | 4 + CLAUDE.md | 1 - Model/XrmSyncOptions.cs | 4 +- README.md | 35 -- Tests/Config/ConfigWriterTests.cs | 352 ------------------ Tests/Config/OptionsValidationTests.cs | 2 +- XrmSync/Commands/XrmSyncCommandBase.cs | 46 +-- XrmSync/Commands/XrmSyncRootCommand.cs | 6 +- XrmSync/Constants/CliOptions.cs | 16 +- .../Extensions/ServiceCollectionExtensions.cs | 1 - XrmSync/Options/ConfigWriter.cs | 61 --- XrmSync/Properties/launchSettings.json | 8 - 12 files changed, 17 insertions(+), 519 deletions(-) delete mode 100644 Tests/Config/ConfigWriterTests.cs delete mode 100644 XrmSync/Options/ConfigWriter.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index e1f4f75..b98392b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +### Unreleased +* Refactor: The configuration format has been updated +* Remove: `--save-config` and `--save-config-to` options - configuration files should be created manually + ### v1.0.0-preview.14 - 06 November 2025 * Add: Validation rule to prevent duplicate webresource creation diff --git a/CLAUDE.md b/CLAUDE.md index c80b33e..ef8bebf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,7 +81,6 @@ The solution is organized into distinct layers with clear separation of concerns - Each profile contains a solution name and list of sync items (Plugin, PluginAnalysis, Webresource) - Profile support (e.g., "default", "dev", "prod") via `--profile` flag - CLI options override configuration file values for standalone execution -- `--save-config` flag generates/updates configuration files from CLI arguments - Root command can execute all sync items in a profile sequentially **Configuration Format**: diff --git a/Model/XrmSyncOptions.cs b/Model/XrmSyncOptions.cs index 6b0aa7f..d81c5e1 100644 --- a/Model/XrmSyncOptions.cs +++ b/Model/XrmSyncOptions.cs @@ -50,9 +50,9 @@ public record WebresourceSyncItem(string FolderPath) : SyncItem public override string SyncType => "Webresource"; } -public record SharedOptions(bool SaveConfig, string? SaveConfigTo, string? ProfileName) +public record SharedOptions(string? ProfileName) { - public static SharedOptions Empty => new(false, null, null); + public static SharedOptions Empty => new((string?)null); } // Command-specific options that can be populated from CLI or profile diff --git a/README.md b/README.md index 31e3481..462ef9a 100644 --- a/README.md +++ b/README.md @@ -100,8 +100,6 @@ xrmsync plugins --dry-run --log-level Debug | `--log-level` | `-l` | Set the minimum log level (Trace, Debug, Information, Warning, Error, Critical) | No | | `--ci-mode` | `--ci` | Enable CI mode which prefixes all warnings and errors | No | | `--profile` | `-p`, `--profile-name` | Name of the profile to load from appsettings.json | No | -| `--save-config` | `--sc` | Save current CLI options to appsettings.json | No | -| `--save-config-to` | | If `--save-config` is specified, override the filename to save to | No | *Required when not present in appsettings.json @@ -115,8 +113,6 @@ xrmsync plugins --dry-run --log-level Debug | `--log-level` | `-l` | Set the minimum log level (Trace, Debug, Information, Warning, Error, Critical) | No | | `--ci-mode` | `--ci` | Enable CI mode which prefixes all warnings and errors | No | | `--profile` | `-p`, `--profile-name` | Name of the profile to load from appsettings.json | No | -| `--save-config` | `--sc` | Save current CLI options to appsettings.json | No | -| `--save-config-to` | | If `--save-config` is specified, override the filename to save to | No | *Required when not present in appsettings.json @@ -140,8 +136,6 @@ The webresource name in Dataverse is determined by the file path relative to the | `--assembly` | `-a` | Path to the plugin assembly (*.dll) | Yes* | | `--prefix` | `-p` | Publisher prefix for unique names | No (Default: "new") | | `--pretty-print` | `--pp` | Pretty print the JSON output | No | -| `--save-config` | `--sc` | Save current CLI options to appsettings.json | No | -| `--save-config-to` | | If `--save-config` is specified, override the filename to save to | No | *Required when not present in appsettings.json @@ -166,10 +160,6 @@ You can analyze an assembly without connecting to Dataverse: xrmsync analyze --assembly "path/to/your/plugin.dll" --pretty-print ``` -You can also save analysis configurations: -```bash -xrmsync analyze --assembly "path/to/your/plugin.dll" --prefix "contoso" --pretty-print --save-config -``` This outputs JSON information about the plugin types, steps, and images found in the assembly. ### Configuration Validation @@ -241,31 +231,6 @@ XrmSync supports JSON configuration files that contain all the necessary setting The configuration uses a profile-based structure under the XrmSync section, with global settings (DryRun, LogLevel, CiMode) and an array of named profiles. Each profile contains a solution name and a list of sync items (Plugin, Webresource, PluginAnalysis). -#### Generating Configuration Files - -You can automatically generate configuration files using the `--save-config` option with any command: - -##### Save sync options to appsettings.json (default) -```bash -xrmsync plugins --assembly "MyPlugin.dll" --solution-name "MyCustomSolution" --save-config -``` - -##### Save analysis options to appsettings.json -```bash -xrmsync analyze --assembly "MyPlugin.dll" --prefix "contoso" --pretty-print --save-config -``` - -##### Save to a custom file -```bash -xrmsync plugins --assembly "MyPlugin.dll" --solution-name "MyCustomSolution" --save-config --save-config-to "my-project.json" -``` - -When using `--save-config`, XrmSync will: -1. Take all the provided CLI options -2. Create or update the target configuration file -3. Merge with existing content if the file already exists -4. Save the configuration in the proper JSON format - #### JSON Schema ```json diff --git a/Tests/Config/ConfigWriterTests.cs b/Tests/Config/ConfigWriterTests.cs deleted file mode 100644 index 9648eb9..0000000 --- a/Tests/Config/ConfigWriterTests.cs +++ /dev/null @@ -1,352 +0,0 @@ -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using NSubstitute; -using System.Text.Json; -using XrmSync.Model; -using XrmSync.Options; - -namespace Tests.Config; - -public class ConfigWriterTests -{ - private readonly ILogger _logger; - - public ConfigWriterTests() - { - _logger = Substitute.For>(); - } - - [Fact] - public async Task SaveConfig_CreatesNewFile_WithProfiles() - { - // Arrange - var tempFile = Path.GetTempFileName(); - File.Delete(tempFile); // Ensure file doesn't exist - - XrmSyncConfiguration config = new ( - DryRun: true, - LogLevel: LogLevel.Debug, - CiMode: false, - Profiles: new List - { - new("default", "TestSolution", new List - { - new PluginSyncItem("test.dll"), - new PluginAnalysisSyncItem("analysis.dll", "tst", true), - new WebresourceSyncItem("wwwroot") - }) - } - ); - - var options = Options.Create(config); - var configWriter = new ConfigWriter(options, _logger); - - try - { - // Act - await configWriter.SaveConfig(tempFile); - - // Assert - Assert.True(File.Exists(tempFile)); - - var content = await File.ReadAllTextAsync(tempFile); - var json = JsonSerializer.Deserialize(content); - - var xrmSyncSection = json.GetProperty("XrmSync"); - Assert.True(xrmSyncSection.GetProperty("DryRun").GetBoolean()); - Assert.Equal("Debug", xrmSyncSection.GetProperty("LogLevel").GetString()); - Assert.False(xrmSyncSection.GetProperty("CiMode").GetBoolean()); - - var profiles = xrmSyncSection.GetProperty("Profiles"); - Assert.Equal(1, profiles.GetArrayLength()); - - var profile = profiles[0]; - Assert.Equal("default", profile.GetProperty("Name").GetString()); - Assert.Equal("TestSolution", profile.GetProperty("SolutionName").GetString()); - - var syncItems = profile.GetProperty("Sync"); - Assert.Equal(3, syncItems.GetArrayLength()); - - var pluginSync = syncItems[0]; - Assert.Equal("Plugin", pluginSync.GetProperty("Type").GetString()); - Assert.Equal("test.dll", pluginSync.GetProperty("AssemblyPath").GetString()); - - var pluginAnalysis = syncItems[1]; - Assert.Equal("PluginAnalysis", pluginAnalysis.GetProperty("Type").GetString()); - Assert.Equal("analysis.dll", pluginAnalysis.GetProperty("AssemblyPath").GetString()); - Assert.Equal("tst", pluginAnalysis.GetProperty("PublisherPrefix").GetString()); - Assert.True(pluginAnalysis.GetProperty("PrettyPrint").GetBoolean()); - - var webresourceSync = syncItems[2]; - Assert.Equal("Webresource", webresourceSync.GetProperty("Type").GetString()); - Assert.Equal("wwwroot", webresourceSync.GetProperty("FolderPath").GetString()); - } - finally - { - if (File.Exists(tempFile)) - File.Delete(tempFile); - } - } - - [Fact] - public async Task SaveConfig_CreatesNewFile_WithMultipleProfiles() - { - // Arrange - var tempFile = Path.GetTempFileName(); - File.Delete(tempFile); // Ensure file doesn't exist - - XrmSyncConfiguration config = new ( - DryRun: true, - LogLevel: LogLevel.Debug, - CiMode: false, - Profiles: new List - { - new("default", "DefaultSolution", new List - { - new PluginSyncItem("default.dll") - }), - new("dev", "DevSolution", new List - { - new PluginSyncItem("dev.dll"), - new PluginAnalysisSyncItem("dev-analysis.dll", "dev", false) - }) - } - ); - - var options = Options.Create(config); - var configWriter = new ConfigWriter(options, _logger); - - try - { - // Act - await configWriter.SaveConfig(tempFile); - - // Assert - Assert.True(File.Exists(tempFile)); - - var content = await File.ReadAllTextAsync(tempFile); - var json = JsonSerializer.Deserialize(content); - - var xrmSyncSection = json.GetProperty("XrmSync"); - var profiles = xrmSyncSection.GetProperty("Profiles"); - Assert.Equal(2, profiles.GetArrayLength()); - - var defaultProfile = profiles[0]; - Assert.Equal("default", defaultProfile.GetProperty("Name").GetString()); - Assert.Equal("DefaultSolution", defaultProfile.GetProperty("SolutionName").GetString()); - - var devProfile = profiles[1]; - Assert.Equal("dev", devProfile.GetProperty("Name").GetString()); - Assert.Equal("DevSolution", devProfile.GetProperty("SolutionName").GetString()); - - var devSyncItems = devProfile.GetProperty("Sync"); - Assert.Equal(2, devSyncItems.GetArrayLength()); - } - finally - { - if (File.Exists(tempFile)) - File.Delete(tempFile); - } - } - - [Fact] - public async Task SaveConfig_MergesWithExistingFile_PreservingOtherSections() - { - // Arrange - var tempFile = Path.GetTempFileName(); - var existingContent = """ - { - "SomeOtherSection": { - "SomeProperty": "SomeValue" - } - } - """; - await File.WriteAllTextAsync(tempFile, existingContent); - - XrmSyncConfiguration config = new ( - DryRun: true, - LogLevel: LogLevel.Debug, - CiMode: false, - Profiles: new List - { - new("default", "TestSolution", new List - { - new PluginSyncItem("test.dll"), - new PluginAnalysisSyncItem("analysis.dll", "new", false), - new WebresourceSyncItem("wwwroot") - }) - } - ); - - var options = Options.Create(config); - var configWriter = new ConfigWriter(options, _logger); - - try - { - // Act - await configWriter.SaveConfig(tempFile); - - // Assert - var content = await File.ReadAllTextAsync(tempFile); - var json = JsonSerializer.Deserialize(content); - - // Check original section is preserved - var otherSection = json.GetProperty("SomeOtherSection"); - Assert.Equal("SomeValue", otherSection.GetProperty("SomeProperty").GetString()); - - // Check XrmSync section is added - var xrmSyncSection = json.GetProperty("XrmSync"); - var profiles = xrmSyncSection.GetProperty("Profiles"); - Assert.Equal(1, profiles.GetArrayLength()); - - var profile = profiles[0]; - Assert.Equal("default", profile.GetProperty("Name").GetString()); - Assert.Equal("TestSolution", profile.GetProperty("SolutionName").GetString()); - } - finally - { - if (File.Exists(tempFile)) - File.Delete(tempFile); - } - } - - [Fact] - public async Task SaveConfig_UpdatesExistingConfig() - { - // Arrange - var tempFile = Path.GetTempFileName(); - var existingContent = """ - { - "XrmSync": { - "DryRun": false, - "LogLevel": "Information", - "CiMode": false, - "Profiles": [ - { - "Name": "default", - "SolutionName": "OldSolution", - "Sync": [ - { - "Type": "Plugin", - "AssemblyPath": "old.dll" - } - ] - } - ] - } - } - """; - await File.WriteAllTextAsync(tempFile, existingContent); - - XrmSyncConfiguration config = new ( - DryRun: true, - LogLevel: LogLevel.Debug, - CiMode: false, - Profiles: new List - { - new("default", "NewSolution", new List - { - new PluginSyncItem("new.dll"), - new PluginAnalysisSyncItem("new-analysis.dll", "new", true) - }), - new("dev", "DevSolution", new List - { - new PluginSyncItem("dev.dll") - }) - } - ); - - var options = Options.Create(config); - var configWriter = new ConfigWriter(options, _logger); - - try - { - // Act - await configWriter.SaveConfig(tempFile); - - // Assert - var content = await File.ReadAllTextAsync(tempFile); - var json = JsonSerializer.Deserialize(content); - - var xrmSyncSection = json.GetProperty("XrmSync"); - - // Check config was updated - Assert.True(xrmSyncSection.GetProperty("DryRun").GetBoolean()); - Assert.Equal("Debug", xrmSyncSection.GetProperty("LogLevel").GetString()); - - var profiles = xrmSyncSection.GetProperty("Profiles"); - Assert.Equal(2, profiles.GetArrayLength()); - - var defaultProfile = profiles[0]; - Assert.Equal("default", defaultProfile.GetProperty("Name").GetString()); - Assert.Equal("NewSolution", defaultProfile.GetProperty("SolutionName").GetString()); - - var defaultSync = defaultProfile.GetProperty("Sync"); - Assert.Equal(2, defaultSync.GetArrayLength()); - Assert.Equal("new.dll", defaultSync[0].GetProperty("AssemblyPath").GetString()); - - var devProfile = profiles[1]; - Assert.Equal("dev", devProfile.GetProperty("Name").GetString()); - Assert.Equal("DevSolution", devProfile.GetProperty("SolutionName").GetString()); - } - finally - { - if (File.Exists(tempFile)) - File.Delete(tempFile); - } - } - - [Fact] - public async Task SaveConfig_UsesDefaultFileName_WhenFilePathIsNull() - { - // Arrange - var currentDir = Directory.GetCurrentDirectory(); - var defaultFile = Path.Combine(currentDir, "appsettings.json"); - - // Clean up if file exists - if (File.Exists(defaultFile)) - File.Delete(defaultFile); - - XrmSyncConfiguration config = new ( - DryRun: false, - LogLevel: LogLevel.Debug, - CiMode: false, - Profiles: new List - { - new("default", "TestSolution", new List - { - new PluginSyncItem("test.dll"), - new PluginAnalysisSyncItem("analysis.dll", "new", false), - new WebresourceSyncItem("wwwroot") - }) - } - ); - - var options = Options.Create(config); - var configWriter = new ConfigWriter(options, _logger); - - try - { - // Act - await configWriter.SaveConfig(); - - // Assert - Assert.True(File.Exists(defaultFile)); - - var content = await File.ReadAllTextAsync(defaultFile); - var json = JsonSerializer.Deserialize(content); - - var xrmSyncSection = json.GetProperty("XrmSync"); - var profiles = xrmSyncSection.GetProperty("Profiles"); - var profile = profiles[0]; - var syncItems = profile.GetProperty("Sync"); - Assert.Equal("test.dll", syncItems[0].GetProperty("AssemblyPath").GetString()); - Assert.Equal("TestSolution", profile.GetProperty("SolutionName").GetString()); - } - finally - { - if (File.Exists(defaultFile)) - File.Delete(defaultFile); - } - } -} diff --git a/Tests/Config/OptionsValidationTests.cs b/Tests/Config/OptionsValidationTests.cs index 1ff0e98..508dc77 100644 --- a/Tests/Config/OptionsValidationTests.cs +++ b/Tests/Config/OptionsValidationTests.cs @@ -8,7 +8,7 @@ namespace Tests.Config; public class OptionsValidationTests { private static SharedOptions CreateSharedOptions(string profileName = "default") => - new SharedOptions(false, null, profileName); + new SharedOptions(profileName); [Fact] public void PluginSyncValidator_ValidOptions_PassesValidation() diff --git a/XrmSync/Commands/XrmSyncCommandBase.cs b/XrmSync/Commands/XrmSyncCommandBase.cs index 5d65fba..50102df 100644 --- a/XrmSync/Commands/XrmSyncCommandBase.cs +++ b/XrmSync/Commands/XrmSyncCommandBase.cs @@ -16,37 +16,21 @@ internal abstract class XrmSyncCommandBase(string name, string description) : Co protected const int E_ERROR = 1; // Shared options available to all commands - protected Option SaveConfigOption { get; private set; } = null!; - protected Option SaveConfigToOption { get; private set; } = null!; protected Option ProfileNameOption { get; private set; } = null!; public Command GetCommand() => this; /// - /// Adds shared options to the command (save-config, save-config-to, profile) + /// Adds shared options to the command (profile) /// protected void AddSharedOptions() { - SaveConfigOption = new(CliOptions.Config.SaveConfig.Primary, CliOptions.Config.SaveConfig.Aliases) + ProfileNameOption = new(CliOptions.Config.Profile.Primary, CliOptions.Config.Profile.Aliases) { - Description = CliOptions.Config.SaveConfig.Description, + Description = CliOptions.Config.Profile.Description, Required = false }; - SaveConfigToOption = new(CliOptions.Config.SaveConfigTo.Primary) - { - Description = CliOptions.Config.SaveConfigTo.Description, - Required = false - }; - - ProfileNameOption = new(CliOptions.Config.LoadConfig.Primary, CliOptions.Config.LoadConfig.Aliases) - { - Description = CliOptions.Config.LoadConfig.Description, - Required = false - }; - - Add(SaveConfigOption); - Add(SaveConfigToOption); Add(ProfileNameOption); } @@ -55,15 +39,13 @@ protected void AddSharedOptions() /// protected SharedOptions GetSharedOptionValues(ParseResult parseResult) { - var saveConfig = parseResult.GetValue(SaveConfigOption); - var saveConfigTo = saveConfig ? parseResult.GetValue(SaveConfigToOption) ?? ConfigReader.CONFIG_FILE_BASE + ".json" : null; var profileName = parseResult.GetValue(ProfileNameOption); - return new (saveConfig, saveConfigTo, profileName); + return new (profileName); } /// - /// Validates configuration and runs the appropriate action (save config or execute) + /// Validates configuration and runs the action /// protected static async Task RunAction( IServiceProvider serviceProvider, @@ -83,22 +65,6 @@ protected static async Task RunAction( return false; } - var sharedOptions = serviceProvider.GetRequiredService>(); - - var (saveConfig, saveConfigTo, _) = sharedOptions.Value; - if (saveConfig) - { - var configWriter = serviceProvider.GetRequiredService(); - - var configPath = string.IsNullOrWhiteSpace(saveConfigTo) ? null : saveConfigTo; - - await configWriter.SaveConfig(configPath, cancellationToken); - Console.WriteLine($"Configuration saved to {saveConfigTo}"); - return true; - } - else - { - return await action(serviceProvider, cancellationToken); - } + return await action(serviceProvider, cancellationToken); } } diff --git a/XrmSync/Commands/XrmSyncRootCommand.cs b/XrmSync/Commands/XrmSyncRootCommand.cs index 508464a..af416a5 100644 --- a/XrmSync/Commands/XrmSyncRootCommand.cs +++ b/XrmSync/Commands/XrmSyncRootCommand.cs @@ -139,7 +139,7 @@ private async Task ExecutePluginSync( if (!string.IsNullOrWhiteSpace(sharedOptions.ProfileName)) { - args.Add(CliOptions.Config.LoadConfig.Primary); + args.Add(CliOptions.Config.Profile.Primary); args.Add(sharedOptions.ProfileName); } @@ -161,7 +161,7 @@ private async Task ExecutePluginAnalysis( if (!string.IsNullOrWhiteSpace(sharedOptions.ProfileName)) { - args.Add(CliOptions.Config.LoadConfig.Primary); + args.Add(CliOptions.Config.Profile.Primary); args.Add(sharedOptions.ProfileName); } @@ -187,7 +187,7 @@ private async Task ExecuteWebresourceSync( if (!string.IsNullOrWhiteSpace(sharedOptions.ProfileName)) { - args.Add(CliOptions.Config.LoadConfig.Primary); + args.Add(CliOptions.Config.Profile.Primary); args.Add(sharedOptions.ProfileName); } diff --git a/XrmSync/Constants/CliOptions.cs b/XrmSync/Constants/CliOptions.cs index fa76353..26904a3 100644 --- a/XrmSync/Constants/CliOptions.cs +++ b/XrmSync/Constants/CliOptions.cs @@ -73,21 +73,7 @@ internal static class LogLevel /// internal static class Config { - public static class SaveConfig - { - public const string Primary = "--save-config"; - public static readonly string[] Aliases = ["--sc"]; - public const string Description = "Save current CLI options to appsettings.json"; - } - - - public static class SaveConfigTo - { - public const string Primary = "--save-config-to"; - public const string Description = $"If {SaveConfig.Primary} is set, save to this file instead of appsettings.json"; - } - - public static class LoadConfig + public static class Profile { public const string Primary = "--profile"; public static readonly string[] Aliases = ["--profile-name", "-p"]; diff --git a/XrmSync/Extensions/ServiceCollectionExtensions.cs b/XrmSync/Extensions/ServiceCollectionExtensions.cs index 96759d9..6361ae6 100644 --- a/XrmSync/Extensions/ServiceCollectionExtensions.cs +++ b/XrmSync/Extensions/ServiceCollectionExtensions.cs @@ -16,7 +16,6 @@ public static IServiceCollection AddXrmSyncConfiguration(this IServiceCollection { services .AddSingleton() - .AddSingleton() .AddSingleton() .AddSingleton(MSOptions.Create(sharedOptions)) .AddSingleton(sp => sp.GetRequiredService().GetConfiguration()) diff --git a/XrmSync/Options/ConfigWriter.cs b/XrmSync/Options/ConfigWriter.cs deleted file mode 100644 index 1ffb493..0000000 --- a/XrmSync/Options/ConfigWriter.cs +++ /dev/null @@ -1,61 +0,0 @@ -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using System.Text.Json; -using System.Text.Json.Serialization; -using XrmSync.Model; -using XrmSync.Model.Exceptions; - -namespace XrmSync.Options; - -public interface IConfigWriter -{ - Task SaveConfig(string? filePath = null, CancellationToken cancellationToken = default); -} - -internal class ConfigWriter(IOptions options, ILogger logger) : IConfigWriter -{ - public async Task SaveConfig(string? filePath = null, CancellationToken cancellationToken = default) - { - var targetFile = filePath ?? $"{ConfigReader.CONFIG_FILE_BASE}.json"; - - logger.LogInformation("Saving configuration to {FilePath}", targetFile); - - var jsonOptions = new JsonSerializerOptions - { - WriteIndented = true, - PropertyNamingPolicy = null, // Keep original casing - Converters = { new JsonStringEnumConverter() } // Serialize enums as strings - }; - - try - { - Dictionary rootConfig; - - // Load existing file if it exists - if (File.Exists(targetFile)) - { - var existingContent = await File.ReadAllTextAsync(targetFile, cancellationToken); - rootConfig = JsonSerializer.Deserialize>(existingContent, jsonOptions) - ?? []; - } - else - { - rootConfig = []; - } - - // Serialize the configuration directly to XrmSync section - var configJson = JsonSerializer.Serialize(options.Value, jsonOptions); - rootConfig[XrmSyncConfigurationBuilder.SectionName.XrmSync] = JsonSerializer.Deserialize>(configJson, jsonOptions); - - // Serialize and save - var json = JsonSerializer.Serialize(rootConfig, jsonOptions); - await File.WriteAllTextAsync(targetFile, json, cancellationToken); - - logger.LogInformation("Configuration saved successfully to {FilePath}", targetFile); - } - catch (Exception ex) - { - throw new XrmSyncException("Failed to save configuration", ex); - } - } -} \ No newline at end of file diff --git a/XrmSync/Properties/launchSettings.json b/XrmSync/Properties/launchSettings.json index 737c4b8..8c43790 100644 --- a/XrmSync/Properties/launchSettings.json +++ b/XrmSync/Properties/launchSettings.json @@ -44,14 +44,6 @@ "commandName": "Project", "commandLineArgs": "" }, - "SaveConfigDryRun": { - "commandName": "Project", - "commandLineArgs": "plugins --save-config --dry-run --assembly \"..\\..\\..\\..\\Samples\\1-DAXIF\\bin\\Debug\\net462\\ILMerged.SamplePlugins.dll\" -n \"Plugins\" -l debug" - }, - "SaveConfigAnalyze": { - "commandName": "Project", - "commandLineArgs": "analyze --save-config --pretty-print --assembly \"..\\..\\..\\..\\Samples\\1-DAXIF\\bin\\Debug\\net462\\ILMerged.SamplePlugins.dll\" --prefix \"dg\"" - }, "WebresourceSync": { "commandName": "Project", "commandLineArgs": "webresources --dry-run --path \"..\\..\\..\\..\\Samples\\5-Webresources\\Webresources\" -l debug" From 046dcd8086b313f3a5f197d7ab8a0ded4de492cf Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Tue, 18 Nov 2025 11:44:37 +0100 Subject: [PATCH 5/7] CHORE: Update example appsettings --- XrmSync.slnx | 1 + appsettings.example.json | 97 ++++++++++++++++------------------------ 2 files changed, 39 insertions(+), 59 deletions(-) diff --git a/XrmSync.slnx b/XrmSync.slnx index e39bb25..d04f6c6 100644 --- a/XrmSync.slnx +++ b/XrmSync.slnx @@ -9,6 +9,7 @@ + diff --git a/appsettings.example.json b/appsettings.example.json index 51c1802..834eb95 100644 --- a/appsettings.example.json +++ b/appsettings.example.json @@ -1,65 +1,44 @@ { "DATAVERSE_URL": "https://your-org.crm4.dynamics.com", "XrmSync": { - "default": { - "Plugin": { - "Sync": { - "AssemblyPath": "bin/Debug/net462/MyPlugin.dll", - "SolutionName": "DefaultSolution", - "LogLevel": "Information", - "DryRun": false - }, - "Analysis": { - "AssemblyPath": "bin/Debug/net462/MyPlugin.dll", - "PublisherPrefix": "new", - "PrettyPrint": false - } + "DryRun": true, + "LogLevel": "Information", + "CiMode": false, + "Profiles": [ + { + "Name": "plugins", + "SolutionName": "PluginSolution", + "Sync": [ + { + "Type": "Plugin", + "AssemblyPath": "bin/Release/net462/MyPlugin.dll" + } + ] + }, + { + "Name": "webresources", + "SolutionName": "WebresourceSolution", + "Sync": [ + { + "Type": "Webresource", + "FolderPath": "src/webresources" + } + ] + }, + { + "Name": "all", + "SolutionName": "SharedSolution", + "Sync": [ + { + "Type": "Plugin", + "AssemblyPath": "bin/Release/net462/MyPlugin.dll" + }, + { + "Type": "Webresource", + "FolderPath": "src/webresources" + } + ] } - }, - "dev": { - "Plugin": { - "Sync": { - "AssemblyPath": "bin/Debug/net462/MyPlugin.dll", - "SolutionName": "DevSolution", - "LogLevel": "Debug", - "DryRun": true - }, - "Analysis": { - "AssemblyPath": "bin/Debug/net462/MyPlugin.dll", - "PublisherPrefix": "dev", - "PrettyPrint": true - } - } - }, - "staging": { - "Plugin": { - "Sync": { - "AssemblyPath": "bin/Release/net462/MyPlugin.dll", - "SolutionName": "StagingSolution", - "LogLevel": "Information", - "DryRun": false - }, - "Analysis": { - "AssemblyPath": "bin/Release/net462/MyPlugin.dll", - "PublisherPrefix": "stg", - "PrettyPrint": false - } - } - }, - "prod": { - "Plugin": { - "Sync": { - "AssemblyPath": "bin/Release/net462/MyPlugin.dll", - "SolutionName": "ProdSolution", - "LogLevel": "Warning", - "DryRun": false - }, - "Analysis": { - "AssemblyPath": "bin/Release/net462/MyPlugin.dll", - "PublisherPrefix": "prod", - "PrettyPrint": false - } - } - } + ] } } From 9df3f9d1dc2ea843538e41232bbde11f1e412005 Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Tue, 18 Nov 2025 12:52:29 +0100 Subject: [PATCH 6/7] REFACTOR: Simplify launchSettings and include sample appsettings --- XrmSync/Properties/launchSettings.json | 50 +-------------- XrmSync/XrmSync.csproj | 3 + XrmSync/appsettings.example.json | 88 ++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 47 deletions(-) create mode 100644 XrmSync/appsettings.example.json diff --git a/XrmSync/Properties/launchSettings.json b/XrmSync/Properties/launchSettings.json index 8c43790..b456f82 100644 --- a/XrmSync/Properties/launchSettings.json +++ b/XrmSync/Properties/launchSettings.json @@ -1,52 +1,8 @@ { "profiles": { - "Sync": { + "XrmSync": { "commandName": "Project", - "commandLineArgs": "plugins" - }, - "DryRunSyncDAXIF": { - "commandName": "Project", - "commandLineArgs": "plugins --dry-run --assembly \"..\\..\\..\\..\\Samples\\1-DAXIF\\bin\\Debug\\net462\\ILMerged.SamplePlugins.dll\" -n \"Plugins\" -l debug" - }, - "DryRunSyncDAXIFFromFile": { - "commandName": "Project", - "commandLineArgs": "plugins --dry-run -l Debug" - }, - "SyncDAXIF": { - "commandName": "Project", - "commandLineArgs": "plugins --assembly \"..\\..\\..\\..\\Samples\\1-DAXIF\\bin\\Debug\\net462\\ILMerged.SamplePlugins.dll\" -n \"Plugins\" -l debug" - }, - "DryRunSyncHybrid": { - "commandName": "Project", - "commandLineArgs": "plugins --dry-run --assembly \"..\\..\\..\\..\\Samples\\2-Hybrid\\bin\\Debug\\net462\\ILMerged.SamplePlugins.dll\" -n \"Plugins\" -l debug" - }, - "SyncHybrid": { - "commandName": "Project", - "commandLineArgs": "plugins --assembly \"..\\..\\..\\..\\Samples\\2-Hybrid\\bin\\Debug\\net462\\ILMerged.SamplePlugins.dll\" -n \"Plugins\" -l debug" - }, - "AnalyzeSampleDAXIF": { - "commandName": "Project", - "commandLineArgs": "analyze --pretty-print --assembly \"..\\..\\..\\..\\Samples\\1-DAXIF\\bin\\Debug\\net462\\ILMerged.SamplePlugins.dll\" --prefix \"dg\"" - }, - "AnalyzeSampleHybrid": { - "commandName": "Project", - "commandLineArgs": "analyze --pretty-print --assembly \"..\\..\\..\\..\\Samples\\2-Hybrid\\bin\\Debug\\net462\\ILMerged.SamplePlugins.dll\" --prefix \"dg\"" - }, - "AnalyzeSampleXrmPluginCore": { - "commandName": "Project", - "commandLineArgs": "analyze --pretty-print --assembly \"..\\..\\..\\..\\Samples\\3-XrmPluginCore\\bin\\Debug\\net462\\ILMerged.SamplePlugins.dll\" --prefix \"dg\"" - }, - "AnalyzeSampleFullDAXIF": { - "commandName": "Project", - "commandLineArgs": "analyze --pretty-print --assembly \"..\\..\\..\\..\\Samples\\4-Full-DAXIF\\bin\\Debug\\net462\\ILMerged.SamplePlugins.dll\" --prefix \"dg\"" - }, - "XrmSyncHelp": { - "commandName": "Project", - "commandLineArgs": "" - }, - "WebresourceSync": { - "commandName": "Project", - "commandLineArgs": "webresources --dry-run --path \"..\\..\\..\\..\\Samples\\5-Webresources\\Webresources\" -l debug" + "commandLineArgs": "--profile hybrid" } } -} \ No newline at end of file +} diff --git a/XrmSync/XrmSync.csproj b/XrmSync/XrmSync.csproj index 748abac..041fdc9 100644 --- a/XrmSync/XrmSync.csproj +++ b/XrmSync/XrmSync.csproj @@ -38,6 +38,9 @@ + + Never + PreserveNewest diff --git a/XrmSync/appsettings.example.json b/XrmSync/appsettings.example.json new file mode 100644 index 0000000..1952e77 --- /dev/null +++ b/XrmSync/appsettings.example.json @@ -0,0 +1,88 @@ +{ + "DATAVERSE_URL": "https://aarsleff-udv.crm4.dynamics.com", + "XrmSync": { + "DryRun": true, + "LogLevel": "Debug", + "CiMode": false, + "Profiles": [ + { + "Name": "daxif", + "SolutionName": "Plugins", + "Sync": [ + { + "Type": "Plugin", + "AssemblyPath": "../../../../Samples/1-DAXIF/bin/Debug/net462/ILMerged.SamplePlugins.dll" + } + ] + }, + { + "Name": "hybrid", + "SolutionName": "Plugins", + "Sync": [ + { + "Type": "Plugin", + "AssemblyPath": "../../../../Samples/2-Hybrid/bin/Debug/net462/ILMerged.SamplePlugins.dll" + } + ] + }, + { + "Name": "analyze-daxif", + "SolutionName": "Plugins", + "Sync": [ + { + "Type": "PluginAnalysis", + "AssemblyPath": "../../../../Samples/1-DAXIF/bin/Debug/net462/ILMerged.SamplePlugins.dll", + "PublisherPrefix": "dg", + "PrettyPrint": true + } + ] + }, + { + "Name": "analyze-hybrid", + "SolutionName": "Plugins", + "Sync": [ + { + "Type": "PluginAnalysis", + "AssemblyPath": "../../../../Samples/2-Hybrid/bin/Debug/net462/ILMerged.SamplePlugins.dll", + "PublisherPrefix": "dg", + "PrettyPrint": true + } + ] + }, + { + "Name": "analyze-xrmplugincore", + "SolutionName": "Plugins", + "Sync": [ + { + "Type": "PluginAnalysis", + "AssemblyPath": "../../../../Samples/3-XrmPluginCore/bin/Debug/net462/ILMerged.SamplePlugins.dll", + "PublisherPrefix": "dg", + "PrettyPrint": true + } + ] + }, + { + "Name": "analyze-full-daxif", + "SolutionName": "Plugins", + "Sync": [ + { + "Type": "PluginAnalysis", + "AssemblyPath": "../../../../Samples/4-Full-DAXIF/bin/Debug/net462/ILMerged.SamplePlugins.dll", + "PublisherPrefix": "dg", + "PrettyPrint": true + } + ] + }, + { + "Name": "webresources-sample", + "SolutionName": "WebresourceSolution", + "Sync": [ + { + "Type": "Webresource", + "FolderPath": "../../../../Samples/5-Webresources/Webresources" + } + ] + } + ] + } +} From 74e155293a04f32f861f339ba4dfd5c55d1c80dc Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Tue, 18 Nov 2025 13:03:03 +0100 Subject: [PATCH 7/7] Add publisher prefix to test script to make it run again --- scripts/Test-Samples.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/Test-Samples.ps1 b/scripts/Test-Samples.ps1 index eabd735..6ff5d18 100644 --- a/scripts/Test-Samples.ps1 +++ b/scripts/Test-Samples.ps1 @@ -76,7 +76,7 @@ function Run-Analyzer { } # Run the analyzer using dotnet run - $analyzeOutput = dotnet run --project $xrmSyncPath -- analyze --assembly $AssemblyPath --pretty-print 2>&1 + $analyzeOutput = dotnet run --project $xrmSyncPath -- analyze --assembly $AssemblyPath --publisher-prefix new --pretty-print 2>&1 if ($LASTEXITCODE -ne 0) { Write-Error "Failed to analyze $SampleName. Output: $analyzeOutput" }