diff --git a/AssemblyAnalyzer/GlobalUsings.cs b/AssemblyAnalyzer/GlobalUsings.cs new file mode 100644 index 0000000..23e2014 --- /dev/null +++ b/AssemblyAnalyzer/GlobalUsings.cs @@ -0,0 +1 @@ +global using ExecutionContext = XrmSync.Model.ExecutionContext; diff --git a/AssemblyAnalyzer/Reader/LocalReader.cs b/AssemblyAnalyzer/Reader/LocalReader.cs index d037324..e368967 100644 --- a/AssemblyAnalyzer/Reader/LocalReader.cs +++ b/AssemblyAnalyzer/Reader/LocalReader.cs @@ -7,7 +7,7 @@ namespace XrmSync.Analyzer.Reader; -internal class LocalReader(ILogger logger, IOptions sharedOptions) : ILocalReader +internal class LocalReader(ILogger logger, IOptions sharedOptions) : ILocalReader { private readonly Dictionary assemblyCache = []; diff --git a/CHANGELOG.md b/CHANGELOG.md index f241561..33fe0b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +### v1.0.0-preview.19 - 24 April 2026 +* Refactor / Fix: Complete rewrite of the option passing logic + ### v1.0.0-preview.18 - 22 April 2026 * Fix: Handling of passing boolean arguments through from CLI to final command * Refactor: Only print the header and Dataverse connection once during execution diff --git a/Dataverse/CustomApiWriter.cs b/Dataverse/CustomApiWriter.cs index 599bcb2..85a262e 100644 --- a/Dataverse/CustomApiWriter.cs +++ b/Dataverse/CustomApiWriter.cs @@ -5,6 +5,7 @@ using XrmSync.Dataverse.Interfaces; using XrmSync.Model; using XrmSync.Model.CustomApi; +using XrmSync.Model.Plugin; namespace XrmSync.Dataverse; diff --git a/Dataverse/DataverseWriter.cs b/Dataverse/DataverseWriter.cs index b428318..fc845e2 100644 --- a/Dataverse/DataverseWriter.cs +++ b/Dataverse/DataverseWriter.cs @@ -14,9 +14,9 @@ internal sealed class DataverseWriter : IDataverseWriter private readonly IOrganizationService service; private readonly ILogger logger; - public DataverseWriter(IOrganizationServiceProvider serviceProvider, ILogger logger, IOptions configuration) + public DataverseWriter(IOrganizationServiceProvider serviceProvider, ILogger logger, IOptions configuration) { - if (configuration.Value.DryRun) + if (configuration.Value.DryRun == true) { throw new XrmSyncException("Cannot perform write operations in dry run mode. Please disable dry run to proceed with writing to Dataverse."); } diff --git a/Dataverse/DryRunDataverseWriter.cs b/Dataverse/DryRunDataverseWriter.cs index e142e09..75d9e82 100644 --- a/Dataverse/DryRunDataverseWriter.cs +++ b/Dataverse/DryRunDataverseWriter.cs @@ -14,9 +14,9 @@ internal class DryRunDataverseWriter : IDataverseWriter { private readonly ILogger logger; - public DryRunDataverseWriter(IOptions configuration, ILogger logger) + public DryRunDataverseWriter(IOptions configuration, ILogger logger) { - if (!configuration.Value.DryRun) + if (configuration.Value.DryRun != true) { throw new XrmSyncException("This writer is intended for dry runs only."); } diff --git a/Dataverse/Extensions/ServiceCollectionExtensions.cs b/Dataverse/Extensions/ServiceCollectionExtensions.cs index 107ab03..f92ec82 100644 --- a/Dataverse/Extensions/ServiceCollectionExtensions.cs +++ b/Dataverse/Extensions/ServiceCollectionExtensions.cs @@ -2,7 +2,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using XrmSync.Dataverse.Interfaces; -using XrmSync.Model; namespace XrmSync.Dataverse.Extensions; @@ -17,9 +16,9 @@ public static IServiceCollection AddDataverseServices(this IServiceCollection se services.AddSingleton(); services.AddSingleton((sp) => { - var options = sp.GetRequiredService>(); + var options = sp.GetRequiredService>(); - return options.Value.DryRun + return options.Value.DryRun == true ? ActivatorUtilities.CreateInstance(sp) : ActivatorUtilities.CreateInstance(sp); }); diff --git a/Dataverse/GlobalUsings.cs b/Dataverse/GlobalUsings.cs new file mode 100644 index 0000000..23e2014 --- /dev/null +++ b/Dataverse/GlobalUsings.cs @@ -0,0 +1 @@ +global using ExecutionContext = XrmSync.Model.ExecutionContext; diff --git a/Dataverse/PluginAssemblyWriter.cs b/Dataverse/PluginAssemblyWriter.cs index 8de7195..9510d76 100644 --- a/Dataverse/PluginAssemblyWriter.cs +++ b/Dataverse/PluginAssemblyWriter.cs @@ -3,6 +3,7 @@ using XrmSync.Dataverse.Interfaces; using XrmSync.Model; using XrmSync.Model.Exceptions; +using XrmSync.Model.Plugin; namespace XrmSync.Dataverse; diff --git a/Model/ExecutionContext.cs b/Model/ExecutionContext.cs new file mode 100644 index 0000000..c6a80b5 --- /dev/null +++ b/Model/ExecutionContext.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.Logging; + +namespace XrmSync.Model; + +/// +/// Carries execution parameters resolved from configuration and CLI overrides. +/// Used as the single DI-registered context replacing both SharedOptions and ExecutionModeOptions. +/// +public record ExecutionContext( + string? SolutionName, + bool? DryRun, + bool? CiMode, + LogLevel? LogLevel, + string? ProfileName) +{ + public static ExecutionContext Empty => new(null, null, null, null, null); +} diff --git a/Model/Identity/IdentityCommandOptions.cs b/Model/Identity/IdentityCommandOptions.cs new file mode 100644 index 0000000..d9e0d45 --- /dev/null +++ b/Model/Identity/IdentityCommandOptions.cs @@ -0,0 +1,7 @@ +namespace XrmSync.Model.Identity; + +public record IdentityCommandOptions(IdentityOperation Operation, string AssemblyPath, string SolutionName, string ClientId, string TenantId) +{ + public static IdentityCommandOptions Empty => new(IdentityOperation.Remove, string.Empty, string.Empty, string.Empty, string.Empty); +} + diff --git a/Model/Plugin/PluginAnalysisCommandOptions.cs b/Model/Plugin/PluginAnalysisCommandOptions.cs new file mode 100644 index 0000000..ec2de79 --- /dev/null +++ b/Model/Plugin/PluginAnalysisCommandOptions.cs @@ -0,0 +1,7 @@ +namespace XrmSync.Model.Plugin; + +public record PluginAnalysisCommandOptions(string AssemblyPath, string PublisherPrefix, bool PrettyPrint) +{ + public static PluginAnalysisCommandOptions Empty => new(string.Empty, "new", false); +} + diff --git a/Model/Plugin/PluginSyncCommandOptions.cs b/Model/Plugin/PluginSyncCommandOptions.cs new file mode 100644 index 0000000..62856f6 --- /dev/null +++ b/Model/Plugin/PluginSyncCommandOptions.cs @@ -0,0 +1,8 @@ +namespace XrmSync.Model.Plugin; + +// Command-specific options that can be populated from CLI or profile +public record PluginSyncCommandOptions(string AssemblyPath, string SolutionName) +{ + public static PluginSyncCommandOptions Empty => new(string.Empty, string.Empty); +} + diff --git a/Model/Webresource/WebresourceSyncCommandOptions.cs b/Model/Webresource/WebresourceSyncCommandOptions.cs new file mode 100644 index 0000000..e0e80b4 --- /dev/null +++ b/Model/Webresource/WebresourceSyncCommandOptions.cs @@ -0,0 +1,7 @@ +namespace XrmSync.Model.Webresource; + +public record WebresourceSyncCommandOptions(string FolderPath, string SolutionName, List? FileExtensions = null) +{ + public static WebresourceSyncCommandOptions Empty => new(string.Empty, string.Empty); +} + diff --git a/Model/XrmSyncOptions.cs b/Model/XrmSyncOptions.cs index ab4bf37..f381c1d 100644 --- a/Model/XrmSyncOptions.cs +++ b/Model/XrmSyncOptions.cs @@ -92,34 +92,13 @@ public record WebresourceSyncItem(string FolderPath, List? FileExtension public override string SyncType => TypeName; } -public record SharedOptions(string? ProfileName) -{ - public static SharedOptions Empty => new((string?)null); -} - -// Command-specific options that can be populated from CLI or profile -public record PluginSyncCommandOptions(string AssemblyPath, string SolutionName) -{ - public static PluginSyncCommandOptions Empty => new(string.Empty, string.Empty); -} - -public record PluginAnalysisCommandOptions(string AssemblyPath, string PublisherPrefix, bool PrettyPrint) -{ - public static PluginAnalysisCommandOptions Empty => new(string.Empty, "new", false); -} - -public record WebresourceSyncCommandOptions(string FolderPath, string SolutionName, List? FileExtensions = null) -{ - public static WebresourceSyncCommandOptions Empty => new(string.Empty, string.Empty); -} - public enum IdentityOperation { Remove, Ensure } -public record IdentitySyncItem(IdentityOperation? Operation = null, string AssemblyPath = "", string? ClientId = null, string? TenantId = null) : SyncItem +public record IdentitySyncItem(IdentityOperation? Operation = null, string AssemblyPath = "", string ClientId = "", string TenantId = "") : SyncItem { public const string TypeName = "Identity"; public static IdentitySyncItem Empty => new(AssemblyPath: string.Empty); @@ -128,12 +107,3 @@ public record IdentitySyncItem(IdentityOperation? Operation = null, string Assem public override string SyncType => Operation.HasValue ? $"{TypeName} ({Operation})" : TypeName; } -public record IdentityCommandOptions(IdentityOperation Operation, string AssemblyPath, string SolutionName, string? ClientId = null, string? TenantId = null) -{ - public static IdentityCommandOptions Empty => new(IdentityOperation.Remove, string.Empty, string.Empty); -} - -public record ExecutionModeOptions(bool DryRun) -{ - public static ExecutionModeOptions Empty => new(false); -} diff --git a/SyncService/Difference/PrintService.cs b/SyncService/Difference/PrintService.cs index eb12442..179c084 100644 --- a/SyncService/Difference/PrintService.cs +++ b/SyncService/Difference/PrintService.cs @@ -8,10 +8,10 @@ namespace XrmSync.SyncService.Difference; internal class PrintService( ILogger log, - IOptions configuration + IOptions configuration ) : IPrintService { - private readonly LogLevel LogLevel = configuration.Value.DryRun ? LogLevel.Information : LogLevel.Debug; + private readonly LogLevel LogLevel = configuration.Value.DryRun == true ? LogLevel.Information : LogLevel.Debug; public void Print(Difference differences, string title, Func namePicker) where TEntity : EntityBase diff --git a/SyncService/GlobalUsings.cs b/SyncService/GlobalUsings.cs new file mode 100644 index 0000000..23e2014 --- /dev/null +++ b/SyncService/GlobalUsings.cs @@ -0,0 +1 @@ +global using ExecutionContext = XrmSync.Model.ExecutionContext; diff --git a/SyncService/IdentitySyncService.cs b/SyncService/IdentitySyncService.cs index 235b7ce..dad6a32 100644 --- a/SyncService/IdentitySyncService.cs +++ b/SyncService/IdentitySyncService.cs @@ -3,7 +3,7 @@ using XrmSync.Dataverse.Interfaces; using XrmSync.Model; using XrmSync.Model.Exceptions; -using XrmSync.SyncService.Difference; +using XrmSync.Model.Identity; namespace XrmSync.SyncService; diff --git a/SyncService/WebresourceSyncService.cs b/SyncService/WebresourceSyncService.cs index 544a842..260191b 100644 --- a/SyncService/WebresourceSyncService.cs +++ b/SyncService/WebresourceSyncService.cs @@ -2,10 +2,8 @@ using Microsoft.Extensions.Options; using XrmSync.Analyzer.Reader; using XrmSync.Dataverse.Interfaces; -using XrmSync.Model; using XrmSync.Model.Exceptions; using XrmSync.Model.Webresource; -using XrmSync.SyncService.Difference; using XrmSync.SyncService.Exceptions; using XrmSync.SyncService.Validation; diff --git a/Tests.Integration/GlobalUsings.cs b/Tests.Integration/GlobalUsings.cs new file mode 100644 index 0000000..23e2014 --- /dev/null +++ b/Tests.Integration/GlobalUsings.cs @@ -0,0 +1 @@ +global using ExecutionContext = XrmSync.Model.ExecutionContext; diff --git a/Tests.Integration/Infrastructure/TestBase.cs b/Tests.Integration/Infrastructure/TestBase.cs index f73f73d..8f50b13 100644 --- a/Tests.Integration/Infrastructure/TestBase.cs +++ b/Tests.Integration/Infrastructure/TestBase.cs @@ -6,7 +6,8 @@ using NSubstitute; using XrmSync.Dataverse.Extensions; using XrmSync.Dataverse.Interfaces; -using XrmSync.Model; +using XrmSync.Model.Plugin; +using XrmSync.Model.Webresource; namespace Tests.Integration.Infrastructure; @@ -70,7 +71,7 @@ private IServiceCollection BuildBaseServices() services.AddSingleton(ServiceProvider); // Configure execution options (not dry run for tests) - services.AddSingleton(Options.Create(new ExecutionModeOptions(DryRun: false))); + services.AddSingleton(Options.Create(new ExecutionContext(null, false, null, null, null))); // Reuse all reader/writer registrations from production code services.AddDataverseServices(); diff --git a/Tests/Config/ConfigValidateCommandTests.cs b/Tests/Config/ConfigValidateCommandTests.cs index aae764d..2feed19 100644 --- a/Tests/Config/ConfigValidateCommandTests.cs +++ b/Tests/Config/ConfigValidateCommandTests.cs @@ -70,7 +70,7 @@ public async Task ConfigValidationOutputWithValidConfigurationOutputsSuccessfull { var configReader = new TestConfigReader(tempFile); var configuration = configReader.GetConfiguration(); - var sharedOptions = Options.Create(SharedOptions.Empty); + var sharedOptions = Options.Create(ExecutionContext.Empty); var builder = new XrmSyncConfigurationBuilder(configuration); var config = builder.Build(); var configOptions = Options.Create(config); @@ -129,7 +129,7 @@ public async Task ConfigValidationOutputWithMultipleConfigsListsAllConfiguration { var configReader = new TestConfigReader(tempFile); var configuration = configReader.GetConfiguration(); - var sharedOptions = Options.Create(SharedOptions.Empty); + var sharedOptions = Options.Create(ExecutionContext.Empty); var builder = new XrmSyncConfigurationBuilder(configuration); var config = builder.Build(); var configOptions = Options.Create(config); @@ -162,7 +162,7 @@ public async Task ConfigValidationOutputWithNoConfigurationHandlesGracefully() { var configReader = new TestConfigReader(tempFile); var configuration = configReader.GetConfiguration(); - var sharedOptions = Options.Create(SharedOptions.Empty); + var sharedOptions = Options.Create(ExecutionContext.Empty); var builder = new XrmSyncConfigurationBuilder(configuration); var config = builder.Build(); var configOptions = Options.Create(config); @@ -186,7 +186,7 @@ public async Task OutputAllValidationResultsWithNoProfilesHandlesGracefully() var configuration = new ConfigurationBuilder().Build(); var config = XrmSyncConfiguration.Empty; var configOptions = Options.Create(config); - var sharedOptions = Options.Create(SharedOptions.Empty); + var sharedOptions = Options.Create(ExecutionContext.Empty); var output = new ConfigValidationOutput(configuration, configOptions, sharedOptions); @@ -210,7 +210,7 @@ public async Task OutputAllValidationResultsWithSingleProfileOutputsValidationRe ] ); var configOptions = Options.Create(config); - var sharedOptions = Options.Create(SharedOptions.Empty); + var sharedOptions = Options.Create(ExecutionContext.Empty); var output = new ConfigValidationOutput(configuration, configOptions, sharedOptions); @@ -235,7 +235,7 @@ public async Task OutputAllValidationResultsWithMultipleProfilesOutputsAllProfil ] ); var configOptions = Options.Create(config); - var sharedOptions = Options.Create(SharedOptions.Empty); + var sharedOptions = Options.Create(ExecutionContext.Empty); var output = new ConfigValidationOutput(configuration, configOptions, sharedOptions); @@ -260,7 +260,7 @@ public async Task OutputAllValidationResultsWithInvalidProfileReportsFailure() ] ); var configOptions = Options.Create(config); - var sharedOptions = Options.Create(SharedOptions.Empty); + var sharedOptions = Options.Create(ExecutionContext.Empty); var output = new ConfigValidationOutput(configuration, configOptions, sharedOptions); diff --git a/Tests/Config/NamedConfigurationTests.cs b/Tests/Config/NamedConfigurationTests.cs index 63ddb8e..60513bf 100644 --- a/Tests/Config/NamedConfigurationTests.cs +++ b/Tests/Config/NamedConfigurationTests.cs @@ -366,8 +366,8 @@ public void IdentityRemoveSyncItemParsesFromConfig() var identitySync = Assert.IsType(profile.Sync[0]); Assert.Equal(IdentityOperation.Remove, identitySync.Operation); Assert.Equal("plugins.dll", identitySync.AssemblyPath); - Assert.Null(identitySync.ClientId); - Assert.Null(identitySync.TenantId); + Assert.Empty(identitySync.ClientId); + Assert.Empty(identitySync.TenantId); } finally { diff --git a/Tests/Config/OptionsValidationTests.cs b/Tests/Config/OptionsValidationTests.cs index 8e9deb9..aabd31b 100644 --- a/Tests/Config/OptionsValidationTests.cs +++ b/Tests/Config/OptionsValidationTests.cs @@ -7,8 +7,8 @@ namespace Tests.Config; public class OptionsValidationTests { - private static SharedOptions CreateSharedOptions(string profileName = "default") => - new SharedOptions(profileName); + private static ExecutionContext CreateSharedOptions(string profileName = "default") => + new ExecutionContext(null, null, null, null, profileName); [Fact] public void PluginSyncValidatorValidOptionsPassesValidation() @@ -579,7 +579,7 @@ public void IdentityEnsureValidatorMissingClientIdThrowsValidationException() { new("default", "TestSolution", new List { - new IdentitySyncItem(IdentityOperation.Ensure, dllPath, null, "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d") + new IdentitySyncItem(IdentityOperation.Ensure, dllPath, string.Empty, "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d") }) } ); @@ -618,7 +618,7 @@ public void IdentityEnsureValidatorMissingTenantIdThrowsValidationException() { new("default", "TestSolution", new List { - new IdentitySyncItem(IdentityOperation.Ensure, dllPath, "d3b5e6a1-2c4f-4a8b-9e1d-7f3c6b8a2e4d", null) + new IdentitySyncItem(IdentityOperation.Ensure, dllPath, "d3b5e6a1-2c4f-4a8b-9e1d-7f3c6b8a2e4d", string.Empty) }) } ); @@ -696,7 +696,7 @@ public void IdentityRemoveValidatorDoesNotRequireClientIdOrTenantId() { new("default", "TestSolution", new List { - new IdentitySyncItem(IdentityOperation.Remove, dllPath, null, null) + new IdentitySyncItem(IdentityOperation.Remove, dllPath, string.Empty, string.Empty) }) } ); @@ -767,7 +767,7 @@ public void ValidatorNoProfilesAndNoProfileNamePassesValidation() // Act & Assert - Should not throw (CLI mode, no profile validation needed) var validator = new XrmSyncConfigurationValidator( Options.Create(config), - Options.Create(new SharedOptions(null))); + Options.Create(new ExecutionContext(null, null, null, null, null))); validator.Validate(ConfigurationScope.PluginSync); } diff --git a/Tests/CustomApis/DifferenceCalculatorCustomApiTests.cs b/Tests/CustomApis/DifferenceCalculatorCustomApiTests.cs index cb34712..853437e 100644 --- a/Tests/CustomApis/DifferenceCalculatorCustomApiTests.cs +++ b/Tests/CustomApis/DifferenceCalculatorCustomApiTests.cs @@ -20,7 +20,7 @@ public DifferenceCalculatorCustomApiTests() { var logger = new LoggerFactory().CreateLogger(); var description = new Description(); - var options = new ExecutionModeOptions(true); + var options = new ExecutionContext(null, true, null, null, null); differenceCalculator = new DifferenceCalculator( new PluginDefinitionComparer(), new PluginStepComparer(), diff --git a/Tests/GlobalUsings.cs b/Tests/GlobalUsings.cs new file mode 100644 index 0000000..23e2014 --- /dev/null +++ b/Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using ExecutionContext = XrmSync.Model.ExecutionContext; diff --git a/Tests/ManagedIdentity/IdentitySyncServiceTests.cs b/Tests/ManagedIdentity/IdentitySyncServiceTests.cs index 44cf519..1e4031d 100644 --- a/Tests/ManagedIdentity/IdentitySyncServiceTests.cs +++ b/Tests/ManagedIdentity/IdentitySyncServiceTests.cs @@ -5,19 +5,19 @@ using XrmSync.Dataverse.Interfaces; using XrmSync.Model; using XrmSync.Model.Exceptions; +using XrmSync.Model.Identity; using XrmSync.SyncService; -using XrmSync.SyncService.Difference; namespace Tests.ManagedIdentity; public class IdentitySyncServiceTests { - private readonly ISolutionReader _solutionReader = Substitute.For(); - private readonly IManagedIdentityReader _managedIdentityReader = Substitute.For(); - private readonly IManagedIdentityWriter _managedIdentityWriter = Substitute.For(); - private readonly ILogger _logger = Substitute.For>(); + private readonly ISolutionReader solutionReader = Substitute.For(); + private readonly IManagedIdentityReader managedIdentityReader = Substitute.For(); + private readonly IManagedIdentityWriter managedIdentityWriter = Substitute.For(); + private readonly ILogger logger = Substitute.For>(); - private readonly Guid _solutionId = Guid.NewGuid(); + private readonly Guid solutionId = Guid.NewGuid(); private const string SolutionName = "TestSolution"; private const string AssemblyPath = "path/to/MyPlugin.dll"; @@ -30,15 +30,15 @@ private IdentitySyncService CreateService( operation, assemblyPath ?? AssemblyPath, solutionName ?? SolutionName, - clientId, - tenantId); + clientId ?? string.Empty, + tenantId ?? string.Empty); return new IdentitySyncService( - _solutionReader, - _managedIdentityReader, - _managedIdentityWriter, + solutionReader, + managedIdentityReader, + managedIdentityWriter, Options.Create(options), - _logger); + logger); } // --- Remove operation tests --- @@ -51,8 +51,8 @@ public async Task RemoveDeletesManagedIdentityWhenLinkedToAssembly() var miId = Guid.NewGuid(); var miRef = new EntityReference("managedidentity", miId); - _solutionReader.RetrieveSolution(SolutionName).Returns((_solutionId, "test")); - _managedIdentityReader.GetPluginAssemblyManagedIdentity(_solutionId, "MyPlugin") + solutionReader.RetrieveSolution(SolutionName).Returns((solutionId, "test")); + managedIdentityReader.GetPluginAssemblyManagedIdentity(solutionId, "MyPlugin") .Returns((assemblyId, miRef)); var service = CreateService(IdentityOperation.Remove); @@ -61,7 +61,7 @@ public async Task RemoveDeletesManagedIdentityWhenLinkedToAssembly() await service.Sync(CancellationToken.None); // Assert - _managedIdentityWriter.Received(1).Remove(miId); + managedIdentityWriter.Received(1).Remove(miId); } [Fact] @@ -70,8 +70,8 @@ public async Task RemoveDoesNotDeleteWhenNoManagedIdentityLinked() // Arrange var assemblyId = Guid.NewGuid(); - _solutionReader.RetrieveSolution(SolutionName).Returns((_solutionId, "test")); - _managedIdentityReader.GetPluginAssemblyManagedIdentity(_solutionId, "MyPlugin") + solutionReader.RetrieveSolution(SolutionName).Returns((solutionId, "test")); + managedIdentityReader.GetPluginAssemblyManagedIdentity(solutionId, "MyPlugin") .Returns((assemblyId, (EntityReference?)null)); var service = CreateService(IdentityOperation.Remove); @@ -80,15 +80,15 @@ public async Task RemoveDoesNotDeleteWhenNoManagedIdentityLinked() await service.Sync(CancellationToken.None); // Assert - _managedIdentityWriter.DidNotReceive().Remove(Arg.Any()); + managedIdentityWriter.DidNotReceive().Remove(Arg.Any()); } [Fact] public async Task RemoveThrowsWhenAssemblyNotFound() { // Arrange - _solutionReader.RetrieveSolution(SolutionName).Returns((_solutionId, "test")); - _managedIdentityReader.GetPluginAssemblyManagedIdentity(_solutionId, "MyPlugin") + solutionReader.RetrieveSolution(SolutionName).Returns((solutionId, "test")); + managedIdentityReader.GetPluginAssemblyManagedIdentity(solutionId, "MyPlugin") .Returns(((Guid, EntityReference?)?)null); var service = CreateService(IdentityOperation.Remove); @@ -106,8 +106,8 @@ public async Task RemoveDerivesAssemblyNameFromPath() // Arrange var assemblyId = Guid.NewGuid(); - _solutionReader.RetrieveSolution(SolutionName).Returns((_solutionId, "test")); - _managedIdentityReader.GetPluginAssemblyManagedIdentity(_solutionId, "Custom.Plugin.Assembly") + solutionReader.RetrieveSolution(SolutionName).Returns((solutionId, "test")); + managedIdentityReader.GetPluginAssemblyManagedIdentity(solutionId, "Custom.Plugin.Assembly") .Returns((assemblyId, (EntityReference?)null)); var service = CreateService(IdentityOperation.Remove, assemblyPath: "some/nested/path/Custom.Plugin.Assembly.dll"); @@ -116,7 +116,7 @@ public async Task RemoveDerivesAssemblyNameFromPath() await service.Sync(CancellationToken.None); // Assert - _managedIdentityReader.Received(1).GetPluginAssemblyManagedIdentity(_solutionId, "Custom.Plugin.Assembly"); + managedIdentityReader.Received(1).GetPluginAssemblyManagedIdentity(solutionId, "Custom.Plugin.Assembly"); } // --- Ensure operation tests --- @@ -130,10 +130,10 @@ public async Task EnsureCreatesManagedIdentityAndLinksToAssembly() var clientId = Guid.NewGuid(); var tenantId = Guid.NewGuid(); - _solutionReader.RetrieveSolution(SolutionName).Returns((_solutionId, "test")); - _managedIdentityReader.GetPluginAssemblyManagedIdentity(_solutionId, "MyPlugin") + solutionReader.RetrieveSolution(SolutionName).Returns((solutionId, "test")); + managedIdentityReader.GetPluginAssemblyManagedIdentity(solutionId, "MyPlugin") .Returns((assemblyId, (EntityReference?)null)); - _managedIdentityWriter.Create(Arg.Any(), Arg.Any(), Arg.Any()) + managedIdentityWriter.Create(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(createdMiId); var service = CreateService(IdentityOperation.Ensure, clientId: clientId.ToString(), tenantId: tenantId.ToString()); @@ -142,10 +142,10 @@ public async Task EnsureCreatesManagedIdentityAndLinksToAssembly() await service.Sync(CancellationToken.None); // Assert - MI was created with correct values - _managedIdentityWriter.Received(1).Create("TestSolution Managed Identity", clientId, tenantId); + managedIdentityWriter.Received(1).Create("TestSolution Managed Identity", clientId, tenantId); // Assert - Assembly was linked to the new MI - _managedIdentityWriter.Received(1).LinkToAssembly(assemblyId, createdMiId); + managedIdentityWriter.Received(1).LinkToAssembly(assemblyId, createdMiId); } [Fact] @@ -155,8 +155,8 @@ public async Task EnsureNoOpsWhenManagedIdentityAlreadyLinked() var assemblyId = Guid.NewGuid(); var existingMiRef = new EntityReference("managedidentity", Guid.NewGuid()); - _solutionReader.RetrieveSolution(SolutionName).Returns((_solutionId, "test")); - _managedIdentityReader.GetPluginAssemblyManagedIdentity(_solutionId, "MyPlugin") + solutionReader.RetrieveSolution(SolutionName).Returns((solutionId, "test")); + managedIdentityReader.GetPluginAssemblyManagedIdentity(solutionId, "MyPlugin") .Returns((assemblyId, existingMiRef)); var service = CreateService(IdentityOperation.Ensure, @@ -166,15 +166,15 @@ public async Task EnsureNoOpsWhenManagedIdentityAlreadyLinked() await service.Sync(CancellationToken.None); // Assert - _managedIdentityWriter.DidNotReceive().Create(Arg.Any(), Arg.Any(), Arg.Any()); - _managedIdentityWriter.DidNotReceive().LinkToAssembly(Arg.Any(), Arg.Any()); + managedIdentityWriter.DidNotReceive().Create(Arg.Any(), Arg.Any(), Arg.Any()); + managedIdentityWriter.DidNotReceive().LinkToAssembly(Arg.Any(), Arg.Any()); } [Fact] public async Task EnsureThrowsWhenClientIdIsNull() { // Arrange - _solutionReader.RetrieveSolution(SolutionName).Returns((_solutionId, "test")); + solutionReader.RetrieveSolution(SolutionName).Returns((solutionId, "test")); var service = CreateService(IdentityOperation.Ensure, clientId: null, tenantId: Guid.NewGuid().ToString()); // Act & Assert @@ -186,7 +186,7 @@ public async Task EnsureThrowsWhenClientIdIsNull() public async Task EnsureThrowsWhenClientIdIsNotAValidGuid() { // Arrange - _solutionReader.RetrieveSolution(SolutionName).Returns((_solutionId, "test")); + solutionReader.RetrieveSolution(SolutionName).Returns((solutionId, "test")); var service = CreateService(IdentityOperation.Ensure, clientId: "not-a-guid", tenantId: Guid.NewGuid().ToString()); // Act & Assert @@ -198,7 +198,7 @@ public async Task EnsureThrowsWhenClientIdIsNotAValidGuid() public async Task EnsureThrowsWhenTenantIdIsNull() { // Arrange - _solutionReader.RetrieveSolution(SolutionName).Returns((_solutionId, "test")); + solutionReader.RetrieveSolution(SolutionName).Returns((solutionId, "test")); var service = CreateService(IdentityOperation.Ensure, clientId: Guid.NewGuid().ToString(), tenantId: null); // Act & Assert @@ -210,7 +210,7 @@ public async Task EnsureThrowsWhenTenantIdIsNull() public async Task EnsureThrowsWhenTenantIdIsNotAValidGuid() { // Arrange - _solutionReader.RetrieveSolution(SolutionName).Returns((_solutionId, "test")); + solutionReader.RetrieveSolution(SolutionName).Returns((solutionId, "test")); var service = CreateService(IdentityOperation.Ensure, clientId: Guid.NewGuid().ToString(), tenantId: "not-a-guid"); // Act & Assert @@ -222,8 +222,8 @@ public async Task EnsureThrowsWhenTenantIdIsNotAValidGuid() public async Task EnsureThrowsWhenAssemblyNotFound() { // Arrange - _solutionReader.RetrieveSolution(SolutionName).Returns((_solutionId, "test")); - _managedIdentityReader.GetPluginAssemblyManagedIdentity(_solutionId, "MyPlugin") + solutionReader.RetrieveSolution(SolutionName).Returns((solutionId, "test")); + managedIdentityReader.GetPluginAssemblyManagedIdentity(solutionId, "MyPlugin") .Returns(((Guid, EntityReference?)?)null); var service = CreateService(IdentityOperation.Ensure, @@ -242,10 +242,10 @@ public async Task EnsureUsesSolutionNameForManagedIdentityName() // Arrange var assemblyId = Guid.NewGuid(); - _solutionReader.RetrieveSolution("CustomSolution").Returns((_solutionId, "custom")); - _managedIdentityReader.GetPluginAssemblyManagedIdentity(_solutionId, "MyPlugin") + solutionReader.RetrieveSolution("CustomSolution").Returns((solutionId, "custom")); + managedIdentityReader.GetPluginAssemblyManagedIdentity(solutionId, "MyPlugin") .Returns((assemblyId, (EntityReference?)null)); - _managedIdentityWriter.Create(Arg.Any(), Arg.Any(), Arg.Any()) + managedIdentityWriter.Create(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Guid.NewGuid()); var service = CreateService(IdentityOperation.Ensure, solutionName: "CustomSolution", @@ -255,7 +255,7 @@ public async Task EnsureUsesSolutionNameForManagedIdentityName() await service.Sync(CancellationToken.None); // Assert - _managedIdentityWriter.Received(1).Create( + managedIdentityWriter.Received(1).Create( "CustomSolution Managed Identity", Arg.Any(), Arg.Any()); } @@ -265,10 +265,10 @@ public async Task EnsureDerivesAssemblyNameFromPath() // Arrange var assemblyId = Guid.NewGuid(); - _solutionReader.RetrieveSolution(SolutionName).Returns((_solutionId, "test")); - _managedIdentityReader.GetPluginAssemblyManagedIdentity(_solutionId, "Custom.Plugin.Assembly") + solutionReader.RetrieveSolution(SolutionName).Returns((solutionId, "test")); + managedIdentityReader.GetPluginAssemblyManagedIdentity(solutionId, "Custom.Plugin.Assembly") .Returns((assemblyId, (EntityReference?)null)); - _managedIdentityWriter.Create(Arg.Any(), Arg.Any(), Arg.Any()) + managedIdentityWriter.Create(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Guid.NewGuid()); var service = CreateService(IdentityOperation.Ensure, assemblyPath: "some/nested/path/Custom.Plugin.Assembly.dll", @@ -278,6 +278,6 @@ public async Task EnsureDerivesAssemblyNameFromPath() await service.Sync(CancellationToken.None); // Assert - _managedIdentityReader.Received(1).GetPluginAssemblyManagedIdentity(_solutionId, "Custom.Plugin.Assembly"); + managedIdentityReader.Received(1).GetPluginAssemblyManagedIdentity(solutionId, "Custom.Plugin.Assembly"); } } diff --git a/Tests/Plugins/AssemblyReaderTests.cs b/Tests/Plugins/AssemblyReaderTests.cs index d350698..8af27b1 100644 --- a/Tests/Plugins/AssemblyReaderTests.cs +++ b/Tests/Plugins/AssemblyReaderTests.cs @@ -16,7 +16,7 @@ public class AssemblyReaderTests public AssemblyReaderTests() { - assemblyReader = new LocalReader(logger, Options.Create(SharedOptions.Empty)); + assemblyReader = new LocalReader(logger, Options.Create(ExecutionContext.Empty)); } [Fact] diff --git a/Tests/Plugins/DifferenceUtilityTests.cs b/Tests/Plugins/DifferenceUtilityTests.cs index 225f40b..f16c1cb 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 ExecutionModeOptions(true); + var options = new ExecutionContext(null, true, null, null, null); differenceUtility = new DifferenceCalculator( new PluginDefinitionComparer(), new PluginStepComparer(), diff --git a/Tests/Webresources/WebresourceSyncServiceTests.cs b/Tests/Webresources/WebresourceSyncServiceTests.cs index 102bb5c..9c9b790 100644 --- a/Tests/Webresources/WebresourceSyncServiceTests.cs +++ b/Tests/Webresources/WebresourceSyncServiceTests.cs @@ -3,7 +3,6 @@ using NSubstitute; using XrmSync.Analyzer.Reader; using XrmSync.Dataverse.Interfaces; -using XrmSync.Model; using XrmSync.Model.Webresource; using XrmSync.SyncService; using XrmSync.SyncService.Difference; diff --git a/Tests/Webresources/WebresourceWriterTests.cs b/Tests/Webresources/WebresourceWriterTests.cs index b3463ce..9e22e0f 100644 --- a/Tests/Webresources/WebresourceWriterTests.cs +++ b/Tests/Webresources/WebresourceWriterTests.cs @@ -3,7 +3,6 @@ using XrmSync.Dataverse; using XrmSync.Dataverse.Context; using XrmSync.Dataverse.Interfaces; -using XrmSync.Model; using XrmSync.Model.Webresource; namespace Tests.Webresources; diff --git a/XrmSync/Commands/CommandOptions.cs b/XrmSync/Commands/CommandOptions.cs new file mode 100644 index 0000000..f0849c3 --- /dev/null +++ b/XrmSync/Commands/CommandOptions.cs @@ -0,0 +1,28 @@ +using Microsoft.Extensions.Logging; +using System.CommandLine; +using XrmSync.Constants; +using XrmSync.Model; + +namespace XrmSync.Commands; + +/// +/// Single source of truth for all shared CLI option instances. +/// Shared between the root command (as profile overrides) and sub-commands (as primary inputs). +/// All string options are nullable: required-ness is validated programmatically since any option +/// may be satisfied by a profile rather than a CLI argument. +/// +internal static class CommandOptions +{ + public static readonly Option Assembly = CliOptions.Assembly.CreateOption(); + public static readonly Option Solution = CliOptions.Solution.CreateOption(); + public static readonly Option Folder = CliOptions.Webresource.CreateOption(); + public static readonly Option FileExtensions = CliOptions.FileExtensions.CreateOption(); + public static readonly Option Prefix = CliOptions.Analysis.Prefix.CreateOption(); + public static readonly Option Operation = CliOptions.ManagedIdentity.Operation.CreateOption(); + public static readonly Option ClientId = CliOptions.ManagedIdentity.ClientId.CreateOption(); + public static readonly Option TenantId = CliOptions.ManagedIdentity.TenantId.CreateOption(); + public static readonly Option DryRun = CliOptions.Execution.DryRun.CreateOption(); + public static readonly Option LogLevel = CliOptions.Logging.LogLevel.CreateOption(); + public static readonly Option CiMode = CliOptions.Logging.CiMode.CreateOption(); + public static readonly Option Profile = CliOptions.Config.Profile.CreateOption(); +} diff --git a/XrmSync/Commands/ConfigCommand.cs b/XrmSync/Commands/ConfigCommand.cs index f0659c6..8b15660 100644 --- a/XrmSync/Commands/ConfigCommand.cs +++ b/XrmSync/Commands/ConfigCommand.cs @@ -1,4 +1,5 @@ using System.CommandLine; +using XrmSync.Model; namespace XrmSync.Commands; @@ -14,7 +15,8 @@ public ConfigCommand() : base("config", "Configuration management commands") public Command GetCommand() => this; /// - /// Config command has no profile sync items to override. + /// Config command does not handle profile sync items. /// - public ProfileOverrideProvider? GetProfileOverrides(Option assembly, Option solution) => null; + public Task ExecuteFromProfile(SyncItem syncItem, ExecutionContext ctx, CancellationToken ct) + => Task.FromResult(null); } diff --git a/XrmSync/Commands/ConfigValidateCommand.cs b/XrmSync/Commands/ConfigValidateCommand.cs index 36a6647..edbec5c 100644 --- a/XrmSync/Commands/ConfigValidateCommand.cs +++ b/XrmSync/Commands/ConfigValidateCommand.cs @@ -20,12 +20,11 @@ public ConfigValidateCommand() : base("validate", "Validate configuration from a private async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { - var sharedOptions = GetSharedOptionValues(parseResult); var all = parseResult.GetValue(AllOption); // Build service provider - no overrides, just validate what's in the config var serviceProvider = GetConfigValidateServices() - .AddXrmSyncConfiguration(sharedOptions) + .AddXrmSyncConfiguration(new ExecutionContext(null, null, null, null, parseResult.GetValue(CommandOptions.Profile))) .AddOptions(baseOptions => baseOptions) // No overrides .BuildServiceProvider(); diff --git a/XrmSync/Commands/IXrmSyncCommand.cs b/XrmSync/Commands/IXrmSyncCommand.cs index 62f2e5d..7478b0b 100644 --- a/XrmSync/Commands/IXrmSyncCommand.cs +++ b/XrmSync/Commands/IXrmSyncCommand.cs @@ -1,4 +1,5 @@ using System.CommandLine; +using XrmSync.Model; namespace XrmSync.Commands; @@ -13,12 +14,9 @@ internal interface IXrmSyncCommand Command GetCommand(); /// - /// Returns a that advertises this command's unique - /// root-level override options and provides merge logic for applying CLI values into - /// profile sync items. The and - /// options are shared, owned by the root command — use them in merge callbacks but do - /// not call Add() on them. - /// Returns null when the command has no profile overrides to advertise. + /// Executes this command using values already resolved from a profile sync item + /// and root-level execution context. Returns null when this command does not + /// handle the given sync item type; returns an exit code otherwise. /// - ProfileOverrideProvider? GetProfileOverrides(Option assembly, Option solution); + Task ExecuteFromProfile(SyncItem syncItem, ExecutionContext ctx, CancellationToken ct); } diff --git a/XrmSync/Commands/IdentityCommand.cs b/XrmSync/Commands/IdentityCommand.cs index 8bfdf1f..de28ac1 100644 --- a/XrmSync/Commands/IdentityCommand.cs +++ b/XrmSync/Commands/IdentityCommand.cs @@ -1,98 +1,87 @@ using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; +using Microsoft.Extensions.Logging; using System.CommandLine; -using XrmSync.Constants; using XrmSync.Extensions; using XrmSync.Model; using XrmSync.Model.Exceptions; +using XrmSync.Model.Identity; using XrmSync.Options; using XrmSync.SyncService.Extensions; using MSOptions = Microsoft.Extensions.Options.Options; namespace XrmSync.Commands; -internal class IdentityCommand : XrmSyncSyncCommandBase +internal class IdentityCommand : XrmSyncCommandBase { - private readonly Option operation; - private readonly Option assemblyFile; - private readonly Option clientId; - private readonly Option tenantId; - - // Root-level override options (advertised to XrmSyncRootCommand via GetProfileOverrides) - private readonly Option rootOperation = CliOptions.ManagedIdentity.Operation.CreateOption(); - private readonly Option rootClientId = CliOptions.ManagedIdentity.ClientId.CreateOption(); - private readonly Option rootTenantId = CliOptions.ManagedIdentity.TenantId.CreateOption(); - public IdentityCommand() : base("identity", "Manage the managed identity linked to a plugin assembly") { - operation = CliOptions.ManagedIdentity.Operation.CreateOption(); - assemblyFile = CliOptions.Assembly.CreateOption(); - clientId = CliOptions.ManagedIdentity.ClientId.CreateOption(); - tenantId = CliOptions.ManagedIdentity.TenantId.CreateOption(); - - Add(operation); - Add(assemblyFile); - Add(clientId); - Add(tenantId); + Add(CommandOptions.Operation); + Add(CommandOptions.Assembly); + Add(CommandOptions.ClientId); + Add(CommandOptions.TenantId); AddSharedOptions(); - AddSyncSharedOptions(); + AddSyncOptions(); SetAction(ExecuteAsync); } - /// - /// Advertises --client-id and --tenant-id as root-level overrides. - /// The shared assembly option is used in the merge callback but owned by the root command. - /// - public override ProfileOverrideProvider? GetProfileOverrides(Option assembly, Option solution) => new( - options: [rootOperation, rootClientId, rootTenantId], - mergeSyncItem: (item, parseResult) => + public override async Task ExecuteFromProfile(SyncItem syncItem, ExecutionContext ctx, CancellationToken ct) + { + if (syncItem is not IdentitySyncItem identity) return null; + + if (identity.Operation == null) { - if (item is not IdentitySyncItem identity) return null; - var operationValue = parseResult.GetValue(rootOperation); - var clientIdValue = parseResult.GetValue(rootClientId); - var tenantIdValue = parseResult.GetValue(rootTenantId); - var assemblyValue = parseResult.GetValue(assembly); - return identity with - { - Operation = operationValue ?? identity.Operation, - AssemblyPath = !string.IsNullOrWhiteSpace(assemblyValue) ? assemblyValue : identity.AssemblyPath, - ClientId = !string.IsNullOrWhiteSpace(clientIdValue) ? clientIdValue : identity.ClientId, - TenantId = !string.IsNullOrWhiteSpace(tenantIdValue) ? tenantIdValue : identity.TenantId - }; - }); + Console.Error.WriteLine("Identity sync item has no operation configured and none was supplied via --operation."); + return E_ERROR; + } + + return await RunCore( + identity.Operation.Value, + identity.AssemblyPath, + ctx.SolutionName ?? string.Empty, + identity.ClientId, + identity.TenantId, + ctx.DryRun, + ctx.CiMode, + ctx.LogLevel, + ctx.ProfileName, + ct); + } private async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { - var operationValue = parseResult.GetValue(operation); - var assemblyPath = parseResult.GetValue(assemblyFile); - var clientIdValue = parseResult.GetValue(clientId); - var tenantIdValue = parseResult.GetValue(tenantId); - var (solutionName, dryRun, logLevel, ciMode) = GetSyncSharedOptionValues(parseResult); - var sharedOptions = GetSharedOptionValues(parseResult); + var operationValue = parseResult.GetValue(CommandOptions.Operation); + var assemblyPath = parseResult.GetValue(CommandOptions.Assembly); + var clientIdValue = parseResult.GetValue(CommandOptions.ClientId); + var tenantIdValue = parseResult.GetValue(CommandOptions.TenantId); + var solutionName = parseResult.GetValue(CommandOptions.Solution); + var dryRun = parseResult.GetValue(CommandOptions.DryRun); + var logLevel = parseResult.GetValue(CommandOptions.LogLevel); + var ciMode = parseResult.GetValue(CommandOptions.CiMode); + var profileName = parseResult.GetValue(CommandOptions.Profile); // Resolve final options eagerly (CLI + profile merge) IdentityOperation? finalOperation; string finalAssemblyPath; string finalSolutionName; - string? finalClientId; - string? finalTenantId; + string finalClientId; + string finalTenantId; - if (sharedOptions.ProfileName == null && !string.IsNullOrWhiteSpace(assemblyPath) && !string.IsNullOrWhiteSpace(solutionName)) + if (profileName == null && !string.IsNullOrWhiteSpace(assemblyPath) && !string.IsNullOrWhiteSpace(solutionName)) { // Standalone mode: all required values supplied via CLI finalOperation = operationValue; finalAssemblyPath = assemblyPath; finalSolutionName = solutionName; - finalClientId = clientIdValue; - finalTenantId = tenantIdValue; + finalClientId = clientIdValue ?? string.Empty; + finalTenantId = tenantIdValue ?? string.Empty; } else { // Profile mode: merge profile values with CLI overrides ProfileConfiguration? profile; - try { profile = LoadProfile(sharedOptions.ProfileName); } + try { profile = LoadProfileAndConfig(profileName).Profile; } catch (XrmSyncException ex) { Console.Error.WriteLine(ex.Message); return E_ERROR; } if (profile == null) @@ -110,10 +99,10 @@ private async Task ExecuteAsync(ParseResult parseResult, CancellationToken : identityItems.FirstOrDefault(); finalOperation = operationValue ?? syncItem?.Operation; - finalAssemblyPath = !string.IsNullOrWhiteSpace(assemblyPath) ? assemblyPath : (syncItem?.AssemblyPath ?? string.Empty); - finalSolutionName = !string.IsNullOrWhiteSpace(solutionName) ? solutionName : profile.SolutionName; - finalClientId = !string.IsNullOrWhiteSpace(clientIdValue) ? clientIdValue : syncItem?.ClientId; - finalTenantId = !string.IsNullOrWhiteSpace(tenantIdValue) ? tenantIdValue : syncItem?.TenantId; + finalAssemblyPath = assemblyPath.GetValueOrDefault(syncItem?.AssemblyPath ?? string.Empty); + finalSolutionName = solutionName.GetValueOrDefault(profile.SolutionName); + finalClientId = clientIdValue.GetValueOrDefault(syncItem?.ClientId ?? string.Empty); + finalTenantId = tenantIdValue.GetValueOrDefault(syncItem?.TenantId ?? string.Empty); } // Validate resolved values @@ -127,17 +116,45 @@ private async Task ExecuteAsync(ParseResult parseResult, CancellationToken if (finalOperation == IdentityOperation.Ensure) { - errors.AddRange(XrmSyncConfigurationValidator.ValidateGuid(finalClientId ?? string.Empty, "Client ID")); - errors.AddRange(XrmSyncConfigurationValidator.ValidateGuid(finalTenantId ?? string.Empty, "Tenant ID")); + errors.AddRange(XrmSyncConfigurationValidator.ValidateGuid(finalClientId, "Client ID")); + errors.AddRange(XrmSyncConfigurationValidator.ValidateGuid(finalTenantId, "Tenant ID")); } if (errors.Count > 0) return ValidationError($"identity --operation {finalOperation?.ToString() ?? ""}", errors); - // Build service provider with validated options + return await RunCore(finalOperation!.Value, finalAssemblyPath, finalSolutionName, finalClientId, finalTenantId, dryRun, ciMode, logLevel, profileName, cancellationToken); + } + + private async Task RunCore( + IdentityOperation operation, + string assemblyPath, + string solutionName, + string clientId, + string tenantId, + bool? dryRun, + bool? ciMode, + LogLevel? logLevel, + string? profileName, + CancellationToken ct) + { + // Validate resolved values (when called from ExecuteAsync, validation already done above) + var errors = new List(); + errors.AddRange(XrmSyncConfigurationValidator.ValidateAssemblyPath(assemblyPath)); + errors.AddRange(XrmSyncConfigurationValidator.ValidateSolutionName(solutionName)); + + if (operation == IdentityOperation.Ensure) + { + errors.AddRange(XrmSyncConfigurationValidator.ValidateGuid(clientId, "Client ID")); + errors.AddRange(XrmSyncConfigurationValidator.ValidateGuid(tenantId, "Tenant ID")); + } + + if (errors.Count > 0) + return ValidationError($"identity --operation {operation}", errors); + var serviceProvider = new ServiceCollection() .AddIdentityService() - .AddXrmSyncConfiguration(sharedOptions) + .AddXrmSyncConfiguration(new ExecutionContext(null, null, null, null, profileName)) .AddOptions( baseOptions => baseOptions with { @@ -145,16 +162,11 @@ private async Task ExecuteAsync(ParseResult parseResult, CancellationToken CiMode = ciMode ?? baseOptions.CiMode, DryRun = dryRun ?? baseOptions.DryRun }) - .AddSingleton(MSOptions.Create(new IdentityCommandOptions(finalOperation!.Value, finalAssemblyPath, finalSolutionName, finalClientId, finalTenantId))) - .AddSingleton(sp => - { - var config = sp.GetRequiredService>().Value; - return MSOptions.Create(new ExecutionModeOptions(config.DryRun)); - }) + .AddSingleton(MSOptions.Create(new IdentityCommandOptions(operation, assemblyPath, solutionName, clientId, tenantId))) .AddLogger() .BuildServiceProvider(); - return await RunAction(serviceProvider, ConfigurationScope.None, CommandAction, cancellationToken) + return await RunAction(serviceProvider, ConfigurationScope.None, SyncCommandAction, ct) ? E_OK : E_ERROR; } diff --git a/XrmSync/Commands/PluginAnalyzeCommand.cs b/XrmSync/Commands/PluginAnalyzeCommand.cs index 5c4bcc4..e7b30f4 100644 --- a/XrmSync/Commands/PluginAnalyzeCommand.cs +++ b/XrmSync/Commands/PluginAnalyzeCommand.cs @@ -7,6 +7,7 @@ using XrmSync.Constants; using XrmSync.Extensions; using XrmSync.Model; +using XrmSync.Model.Plugin; using XrmSync.Model.Exceptions; using XrmSync.Options; using MSOptions = Microsoft.Extensions.Options.Options; @@ -15,70 +16,48 @@ namespace XrmSync.Commands; internal class PluginAnalyzeCommand : XrmSyncCommandBase { - - private readonly Option assemblyFile; - private readonly Option prefix; - private readonly Option prettyPrint; - - // Root-level override options (advertised to XrmSyncRootCommand via GetProfileOverrides) - private readonly Option rootPrefix = CliOptions.Analysis.Prefix.CreateOption(); + private static readonly Option PrettyPrint = CliOptions.Analysis.PrettyPrint.CreateOption(); public PluginAnalyzeCommand() : base("analyze", "Analyze a plugin assembly and output info as JSON") { - assemblyFile = CliOptions.Assembly.CreateOption(); - prefix = CliOptions.Analysis.Prefix.CreateOption(); - prettyPrint = CliOptions.Analysis.PrettyPrint.CreateOption(); - - Add(assemblyFile); - Add(prefix); - Add(prettyPrint); + Add(CommandOptions.Assembly); + Add(CommandOptions.Prefix); + Add(PrettyPrint); AddSharedOptions(); SetAction(ExecuteAsync); } - /// - /// Advertises --prefix as a root-level override. - /// The shared assembly option is used in the merge callback but owned by the root command. - /// - public override ProfileOverrideProvider? GetProfileOverrides(Option assembly, Option solution) => new( - options: [rootPrefix], - mergeSyncItem: (item, parseResult) => - { - if (item is not PluginAnalysisSyncItem analysis) return null; - var assemblyValue = parseResult.GetValue(assembly); - var prefixValue = parseResult.GetValue(rootPrefix); - return analysis with - { - AssemblyPath = !string.IsNullOrWhiteSpace(assemblyValue) ? assemblyValue : analysis.AssemblyPath, - PublisherPrefix = !string.IsNullOrWhiteSpace(prefixValue) ? prefixValue : analysis.PublisherPrefix - }; - }); + public override async Task ExecuteFromProfile(SyncItem syncItem, ExecutionContext ctx, CancellationToken ct) + { + if (syncItem is not PluginAnalysisSyncItem analysis) return null; + return await RunCore(analysis.AssemblyPath, analysis.PublisherPrefix, analysis.PrettyPrint, ctx.ProfileName, ct); + } private async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { - var assemblyPath = parseResult.GetValue(assemblyFile); - var publisherPrefix = parseResult.GetValue(prefix); - var prettyPrint = parseResult.GetValue(this.prettyPrint); - var sharedOptions = GetSharedOptionValues(parseResult); + var assemblyPath = parseResult.GetValue(CommandOptions.Assembly); + var publisherPrefix = parseResult.GetValue(CommandOptions.Prefix); + var prettyPrintValue = parseResult.GetValue(PrettyPrint); + var profileName = parseResult.GetValue(CommandOptions.Profile); // Resolve final options eagerly (CLI + profile merge) string finalAssemblyPath; string finalPublisherPrefix; bool finalPrettyPrint; - if (sharedOptions.ProfileName == null && !string.IsNullOrWhiteSpace(assemblyPath) && !string.IsNullOrWhiteSpace(publisherPrefix)) + if (profileName == null && !string.IsNullOrWhiteSpace(assemblyPath) && !string.IsNullOrWhiteSpace(publisherPrefix)) { // Standalone mode: all required values supplied via CLI finalAssemblyPath = assemblyPath; finalPublisherPrefix = publisherPrefix; - finalPrettyPrint = prettyPrint; + finalPrettyPrint = prettyPrintValue; } else { // Profile mode: merge profile values with CLI overrides ProfileConfiguration? profile; - try { profile = LoadProfile(sharedOptions.ProfileName); } + try { profile = LoadProfileAndConfig(profileName).Profile; } catch (XrmSyncException ex) { Console.Error.WriteLine(ex.Message); return E_ERROR; } if (profile == null) @@ -90,32 +69,40 @@ private async Task ExecuteAsync(ParseResult parseResult, CancellationToken // Sync item is optional — if absent, CLI must supply all analysis-specific values var pluginAnalysisItem = profile.Sync.OfType().FirstOrDefault(); - finalAssemblyPath = !string.IsNullOrWhiteSpace(assemblyPath) ? assemblyPath : (pluginAnalysisItem?.AssemblyPath ?? string.Empty); - finalPublisherPrefix = !string.IsNullOrWhiteSpace(publisherPrefix) ? publisherPrefix : (pluginAnalysisItem?.PublisherPrefix ?? string.Empty); - finalPrettyPrint = prettyPrint || (pluginAnalysisItem?.PrettyPrint ?? false); + finalAssemblyPath = assemblyPath.GetValueOrDefault(pluginAnalysisItem?.AssemblyPath ?? string.Empty); + finalPublisherPrefix = publisherPrefix.GetValueOrDefault(pluginAnalysisItem?.PublisherPrefix ?? string.Empty); + finalPrettyPrint = prettyPrintValue || (pluginAnalysisItem?.PrettyPrint ?? false); } - // Validate resolved values - var errors = XrmSyncConfigurationValidator.ValidateAssemblyPath(finalAssemblyPath) - .Concat(XrmSyncConfigurationValidator.ValidatePublisherPrefix(finalPublisherPrefix)) + return await RunCore(finalAssemblyPath, finalPublisherPrefix, finalPrettyPrint, profileName, cancellationToken); + } + + private async Task RunCore( + string assemblyPath, + string publisherPrefix, + bool prettyPrintValue, + string? profileName, + CancellationToken ct) + { + var errors = XrmSyncConfigurationValidator.ValidateAssemblyPath(assemblyPath) + .Concat(XrmSyncConfigurationValidator.ValidatePublisherPrefix(publisherPrefix)) .ToList(); if (errors.Count > 0) return ValidationError("analyze", errors); - // Build service provider with validated options var serviceProvider = GetAnalyzerServices() - .AddXrmSyncConfiguration(sharedOptions) + .AddXrmSyncConfiguration(new ExecutionContext(null, null, null, null, profileName)) .AddOptions(baseOptions => baseOptions) - .AddSingleton(MSOptions.Create(new PluginAnalysisCommandOptions(finalAssemblyPath, finalPublisherPrefix, finalPrettyPrint))) + .AddSingleton(MSOptions.Create(new PluginAnalysisCommandOptions(assemblyPath, publisherPrefix, prettyPrintValue))) .AddLogger() .BuildServiceProvider(); - return await RunAction(serviceProvider, ConfigurationScope.None, CommandAction, cancellationToken) + return await RunAction(serviceProvider, ConfigurationScope.None, AnalyzeCommandAction, ct) ? E_OK : E_ERROR; } - private static async Task CommandAction(IServiceProvider serviceProvider, CancellationToken cancellationToken) + private static async Task AnalyzeCommandAction(IServiceProvider serviceProvider, CancellationToken cancellationToken) { return await Task.Run(() => { @@ -145,9 +132,7 @@ private static async Task CommandAction(IServiceProvider serviceProvider, private static IServiceCollection GetAnalyzerServices(IServiceCollection? services = null) { services ??= new ServiceCollection(); - services.AddAssemblyAnalyzer(); - return services; } } diff --git a/XrmSync/Commands/PluginSyncCommand.cs b/XrmSync/Commands/PluginSyncCommand.cs index aec7fca..5913eab 100644 --- a/XrmSync/Commands/PluginSyncCommand.cs +++ b/XrmSync/Commands/PluginSyncCommand.cs @@ -1,57 +1,47 @@ using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; +using Microsoft.Extensions.Logging; using System.CommandLine; -using XrmSync.Constants; using XrmSync.Extensions; using XrmSync.Model; +using XrmSync.Model.Plugin; using XrmSync.Options; using XrmSync.SyncService.Extensions; using MSOptions = Microsoft.Extensions.Options.Options; namespace XrmSync.Commands; -internal class PluginSyncCommand : XrmSyncSyncCommandBase +internal class PluginSyncCommand : XrmSyncCommandBase { - private readonly Option assemblyFile; - public PluginSyncCommand() : base("plugins", "Synchronize plugins in a plugin assembly with Dataverse") { - assemblyFile = CliOptions.Assembly.CreateOption(); - - Add(assemblyFile); + Add(CommandOptions.Assembly); AddSharedOptions(); - AddSyncSharedOptions(); + AddSyncOptions(); SetAction(ExecuteAsync); } - /// - /// Plugin sync has no unique override options — it only uses the shared assembly + solution options. - /// - public override ProfileOverrideProvider? GetProfileOverrides(Option assembly, Option solution) => new( - options: [], - mergeSyncItem: (item, parseResult) => - { - if (item is not PluginSyncItem plugin) return null; - var assemblyValue = parseResult.GetValue(assembly); - return plugin with - { - AssemblyPath = !string.IsNullOrWhiteSpace(assemblyValue) ? assemblyValue : plugin.AssemblyPath - }; - }); + public override async Task ExecuteFromProfile(SyncItem syncItem, ExecutionContext ctx, CancellationToken ct) + { + if (syncItem is not PluginSyncItem plugin) return null; + return await RunCore(plugin.AssemblyPath, ctx.SolutionName ?? string.Empty, ctx.DryRun, ctx.CiMode, ctx.LogLevel, ctx.ProfileName, ct); + } private async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { - var assemblyPath = parseResult.GetValue(assemblyFile); - var (solutionName, dryRun, logLevel, ciMode) = GetSyncSharedOptionValues(parseResult); - var sharedOptions = GetSharedOptionValues(parseResult); + var assemblyPath = parseResult.GetValue(CommandOptions.Assembly); + var solutionName = parseResult.GetValue(CommandOptions.Solution); + var dryRun = parseResult.GetValue(CommandOptions.DryRun); + var logLevel = parseResult.GetValue(CommandOptions.LogLevel); + var ciMode = parseResult.GetValue(CommandOptions.CiMode); + var profileName = parseResult.GetValue(CommandOptions.Profile); // Resolve final options eagerly (CLI + profile merge) string finalAssemblyPath; string finalSolutionName; - if (sharedOptions.ProfileName == null && !string.IsNullOrWhiteSpace(assemblyPath) && !string.IsNullOrWhiteSpace(solutionName)) + if (profileName == null && !string.IsNullOrWhiteSpace(assemblyPath) && !string.IsNullOrWhiteSpace(solutionName)) { // Standalone mode: all required values supplied via CLI finalAssemblyPath = assemblyPath; @@ -61,7 +51,7 @@ private async Task ExecuteAsync(ParseResult parseResult, CancellationToken { // Profile mode: merge profile values with CLI overrides ProfileConfiguration? profile; - try { profile = LoadProfile(sharedOptions.ProfileName); } + try { profile = LoadProfileAndConfig(profileName).Profile; } catch (Model.Exceptions.XrmSyncException ex) { Console.Error.WriteLine(ex.Message); return E_ERROR; } if (profile == null) @@ -73,20 +63,30 @@ private async Task ExecuteAsync(ParseResult parseResult, CancellationToken // Sync item is optional — if absent, CLI must supply all plugin-specific values var pluginSyncItem = profile.Sync.OfType().FirstOrDefault(); - finalAssemblyPath = !string.IsNullOrWhiteSpace(assemblyPath) ? assemblyPath : (pluginSyncItem?.AssemblyPath ?? string.Empty); - finalSolutionName = !string.IsNullOrWhiteSpace(solutionName) ? solutionName : profile.SolutionName; + finalAssemblyPath = assemblyPath.GetValueOrDefault(pluginSyncItem?.AssemblyPath ?? string.Empty); + finalSolutionName = solutionName.GetValueOrDefault(profile.SolutionName); } - // Validate resolved values - var errors = XrmSyncConfigurationValidator.ValidateAssemblyPath(finalAssemblyPath) - .Concat(XrmSyncConfigurationValidator.ValidateSolutionName(finalSolutionName)) + return await RunCore(finalAssemblyPath, finalSolutionName, dryRun, ciMode, logLevel, profileName, cancellationToken); + } + + private async Task RunCore( + string assemblyPath, + string solutionName, + bool? dryRun, + bool? ciMode, + LogLevel? logLevel, + string? profileName, + CancellationToken ct) + { + var errors = XrmSyncConfigurationValidator.ValidateAssemblyPath(assemblyPath) + .Concat(XrmSyncConfigurationValidator.ValidateSolutionName(solutionName)) .ToList(); if (errors.Count > 0) return ValidationError("plugins", errors); - // Build service provider with validated options var serviceProvider = GetPluginSyncServices() - .AddXrmSyncConfiguration(sharedOptions) + .AddXrmSyncConfiguration(new ExecutionContext(null, null, null, null, profileName)) .AddOptions( baseOptions => baseOptions with { @@ -94,16 +94,11 @@ private async Task ExecuteAsync(ParseResult parseResult, CancellationToken CiMode = ciMode ?? baseOptions.CiMode, DryRun = dryRun ?? baseOptions.DryRun }) - .AddSingleton(MSOptions.Create(new PluginSyncCommandOptions(finalAssemblyPath, finalSolutionName))) - .AddSingleton(sp => - { - var config = sp.GetRequiredService>().Value; - return MSOptions.Create(new ExecutionModeOptions(config.DryRun)); - }) + .AddSingleton(MSOptions.Create(new PluginSyncCommandOptions(assemblyPath, solutionName))) .AddLogger() .BuildServiceProvider(); - return await RunAction(serviceProvider, ConfigurationScope.None, CommandAction, cancellationToken) + return await RunAction(serviceProvider, ConfigurationScope.None, SyncCommandAction, ct) ? E_OK : E_ERROR; } @@ -111,9 +106,7 @@ private async Task ExecuteAsync(ParseResult parseResult, CancellationToken private static IServiceCollection GetPluginSyncServices(IServiceCollection? services = null) { services ??= new ServiceCollection(); - services.AddPluginSyncService(); - return services; } } diff --git a/XrmSync/Commands/ProfileOverrideProvider.cs b/XrmSync/Commands/ProfileOverrideProvider.cs deleted file mode 100644 index b528dd9..0000000 --- a/XrmSync/Commands/ProfileOverrideProvider.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.CommandLine; -using System.CommandLine.Parsing; -using XrmSync.Model; - -namespace XrmSync.Commands; - -/// -/// Advertises a command's unique root-level override options and provides merge logic -/// for applying CLI override values into profile sync items. -/// -internal sealed class ProfileOverrideProvider( - IReadOnlyList