diff --git a/src/dvx.Tests/MarkdownBuilderTests.cs b/src/dvx.Tests/MarkdownBuilderTests.cs new file mode 100644 index 0000000..43c6294 --- /dev/null +++ b/src/dvx.Tests/MarkdownBuilderTests.cs @@ -0,0 +1,49 @@ +using dvx.Utility; +using Shouldly; +using Xunit; + +namespace dvx.Tests +{ + public class MarkdownBuilderTests + { + [Fact] + public void BasicFormatting_Works() + { + var md = new MarkdownBuilder(); + md.Heading(1, "Title") + .Paragraph("Hello world") + .Quote("Warning") + .HorizontalLine(); + + var result = md.ToString(); + result.ShouldContain("# Title"); + result.ShouldContain("Hello world"); + result.ShouldContain("> Warning"); + result.ShouldContain("---"); + } + + [Fact] + public void Paragraph_Indentation_Works() + { + var md = new MarkdownBuilder(); + md.Paragraph("Indented text", 1); + + md.ToString().ShouldBe($" Indented text{Environment.NewLine}{Environment.NewLine}"); + } + + [Fact] + public void Table_Alignment_Works() + { + var md = new MarkdownBuilder(); + var headers = new[] { "A", "Long Header" }; + var rows = new List { new[] { "Short", "Val" } }; + + md.Table(headers, rows); + var result = md.ToString(); + + result.ShouldContain("| A | Long Header |"); + result.ShouldContain("| ----- | ----------- |"); + result.ShouldContain("| Short | Val |"); + } + } +} diff --git a/src/dvx.Tests/PluginDiscoveryTests.cs b/src/dvx.Tests/PluginDiscoveryTests.cs index c2faa0f..96da6b9 100644 --- a/src/dvx.Tests/PluginDiscoveryTests.cs +++ b/src/dvx.Tests/PluginDiscoveryTests.cs @@ -142,6 +142,18 @@ public class TestPluginCustomApi : IPlugin public void Execute(IServiceProvider serviceProvider) { } } + [PluginStep(Entity = "account", Message = "Create", Stage = Stage.PreOperation, ExecutionOrder = 10)] + public class TestPluginExplicitOrder : IPlugin + { + public void Execute(IServiceProvider serviceProvider) { } + } + + [PluginStep(Entity = "account", Message = "Create", Stage = Stage.PreOperation)] + public class TestPluginImplicitOrder : IPlugin + { + public void Execute(IServiceProvider serviceProvider) { } + } + // [CustomApi] takes precedence over [PluginStep] on the same class — must still be excluded. [CustomApi] [PluginStep("account", "Create", Stage.PostOperation)] @@ -456,5 +468,27 @@ public void CustomApiClass_WithPluginStep_ExcludedFromResults() var defs = Discover(); defs.ShouldNotContain(d => d.TypeFullName!.EndsWith(nameof(TestPluginCustomApiWithStep))); } + + // ── Execution Order Explicitness ─────────────────────────────────────── + + [Fact] + public void ExecutionOrder_WhenProvidedInAttribute_IsExplicit() + { + var def = Discover() + .Single(d => d.TypeFullName!.EndsWith(nameof(TestPluginExplicitOrder))); + + def.ExecutionOrder.ShouldBe(10); + def.IsExecutionOrderExplicit.ShouldBeTrue(); + } + + [Fact] + public void ExecutionOrder_WhenOmittedInAttribute_IsImplicit() + { + var def = Discover() + .Single(d => d.TypeFullName!.EndsWith(nameof(TestPluginImplicitOrder))); + + def.ExecutionOrder.ShouldBe(1); + def.IsExecutionOrderExplicit.ShouldBeFalse(); + } } } diff --git a/src/dvx.Tests/PluginStepDefinitionTests.cs b/src/dvx.Tests/PluginStepDefinitionTests.cs index 4c60ed5..674657d 100644 --- a/src/dvx.Tests/PluginStepDefinitionTests.cs +++ b/src/dvx.Tests/PluginStepDefinitionTests.cs @@ -91,5 +91,25 @@ public void StepName_DifferentStages_ProduceDifferentNames() pre.StepName.ShouldNotBe(post.StepName); } + + [Fact] + public void ExecutionOrder_Default_IsOneAndNotExplicit() + { + var def = new PluginStepDefinition(); + def.ExecutionOrder.ShouldBe(1); + def.IsExecutionOrderExplicit.ShouldBeFalse(); + } + + [Fact] + public void ExecutionOrder_WhenSet_IsExplicit() + { + var def = new PluginStepDefinition { ExecutionOrder = 1 }; + def.ExecutionOrder.ShouldBe(1); + def.IsExecutionOrderExplicit.ShouldBeTrue(); + + def = new PluginStepDefinition { ExecutionOrder = 5 }; + def.ExecutionOrder.ShouldBe(5); + def.IsExecutionOrderExplicit.ShouldBeTrue(); + } } } diff --git a/src/dvx.Tests/ReportGeneratorTests.cs b/src/dvx.Tests/ReportGeneratorTests.cs new file mode 100644 index 0000000..e6a21b3 --- /dev/null +++ b/src/dvx.Tests/ReportGeneratorTests.cs @@ -0,0 +1,138 @@ +using dvx.Models; +using dvx.Services; +using Shouldly; +using Xunit; + +namespace dvx.Tests +{ + public class ReportGeneratorTests + { + [Fact] + public void GenerateMarkdown_ContainsBothViews() + { + var definitions = new List + { + new() { TypeFullName = "P1", Entity = "account", Message = "Create", Stage = 20, ExecutionOrder = 1 }, + new() { TypeFullName = "P2", Entity = "contact", Message = "Update", Stage = 40, ExecutionOrder = 1 } + }; + + var gen = new ReportGenerator(); + var md = gen.GenerateMarkdown(definitions); + + md.ShouldContain("## By Message then Entity"); + md.ShouldContain("## By Entity then Message"); + md.ShouldContain("### Message: Create"); + md.ShouldContain("#### Entity: account"); + md.ShouldContain("### Entity: contact"); + md.ShouldContain("#### Message: Update"); + } + + [Fact] + public void GenerateMarkdown_UnorderedPlugins_ShowsWarning() + { + var definitions = new List + { + new() { TypeFullName = "P1", Entity = "account", Message = "Create", Stage = 20, ExecutionOrder = 1 }, + // Second one has default order 1, implicit + new() { TypeFullName = "P2", Entity = "account", Message = "Create", Stage = 20 } + }; + + var gen = new ReportGenerator(); + var md = gen.GenerateMarkdown(definitions); + + md.ShouldContain("⚠️ Warning"); + md.ShouldContain("asterix (*) beside the Order number"); + md.ShouldContain("1*"); + } + + [Fact] + public void GenerateMarkdown_PadsColumnsAndInsertsEmptyRows() + { + var definitions = new List + { + new() { TypeFullName = "Short", Entity = "a", Message = "m", Stage = 10, ExecutionOrder = 1 }, + new() { TypeFullName = "VeryLongPluginNameThatShouldForcePadding", Entity = "a", Message = "m", Stage = 10, ExecutionOrder = 2 }, + new() { TypeFullName = "NextStage", Entity = "a", Message = "m", Stage = 20, ExecutionOrder = 1 } + }; + + var gen = new ReportGenerator(); + var md = gen.GenerateMarkdown(definitions); + + // Check padding + md.ShouldContain("| PreValidation | 1 | Sync | Short | - |"); + md.ShouldContain("| PreValidation | 2 | Sync | VeryLongPluginNameThatShouldForcePadding | - |"); + + // Check empty row between Stage 10 and 20 + md.ShouldContain("| | | | | |"); + md.ShouldContain("| PreOperation | 1 | Sync | NextStage | - |"); + } + + [Fact] + public void GenerateMarkdown_SortsByStageThenModeThenOrder() + { + var definitions = new List + { + new() { TypeFullName = "P2", Entity = "a", Message = "m", Stage = 40, Mode = 0, ExecutionOrder = 2 }, + new() { TypeFullName = "P3", Entity = "a", Message = "m", Stage = 40, Mode = 1, ExecutionOrder = 1 }, + new() { TypeFullName = "P1", Entity = "a", Message = "m", Stage = 40, Mode = 0, ExecutionOrder = 1 } + }; + + var gen = new ReportGenerator(); + var md = gen.GenerateMarkdown(definitions); + + // Should be P1 (Sync, 1), P2 (Sync, 2), P3 (Async, 1) + var lines = md.Split('\n').Select(l => l.Trim()).ToList(); + var p1Index = lines.FindIndex(l => l.Contains("P1")); + var p2Index = lines.FindIndex(l => l.Contains("P2")); + var p3Index = lines.FindIndex(l => l.Contains("P3")); + + p1Index.ShouldBeLessThan(p2Index); + p2Index.ShouldBeLessThan(p3Index); + } + + [Fact] + public void GenerateCsv_ContainsAllColumns() + { + var definitions = new List + { + new() + { + TypeFullName = "MyPlugin", + Entity = "account", + Message = "Create", + Stage = 20, + Mode = 0, + ExecutionOrder = 5, + Description = "Some desc" + } + }; + + var gen = new ReportGenerator(); + var csv = gen.GenerateCsv(definitions); + + csv.ShouldContain("Type,Entity,Message,Stage,Mode,ExecutionOrder,IsExplicit,Description"); + csv.ShouldContain("MyPlugin,account,Create,PreOperation,Sync,5,True,Some desc"); + } + + [Fact] + public void EscapeCsv_HandlesSpecialCharacters() + { + var definitions = new List + { + new() + { + TypeFullName = "P", + Entity = "e", + Message = "m", + Stage = 20, + Description = "Comma, \"Quote\", New\nLine" + } + }; + + var gen = new ReportGenerator(); + var csv = gen.GenerateCsv(definitions); + + csv.ShouldContain("\"Comma, \"\"Quote\"\", New\nLine\""); + } + } +} diff --git a/src/dvx/Commands/ReportCommand.cs b/src/dvx/Commands/ReportCommand.cs new file mode 100644 index 0000000..b95b2f1 --- /dev/null +++ b/src/dvx/Commands/ReportCommand.cs @@ -0,0 +1,108 @@ +using System.CommandLine; +using System.CommandLine.Invocation; +using dvx.Commands.Shared; +using dvx.Config; +using dvx.Output; +using dvx.Services; +using Microsoft.Extensions.Logging; + +namespace dvx.Commands +{ + /// + /// dvx plugin report — scans the local project for [PluginStep] attributes, + /// discovers registrations via reflection on the built assembly, and generates a + /// registration report in Markdown and CSV formats. + /// + public static class ReportCommand + { + public static Command Build(ILoggerFactory loggerFactory) + { + var cmd = new Command("report", "Generate a report of plugin registrations in the project."); + + var project = CommandOptions.Project(); + var config = CommandOptions.Config(); + var output = new Option( + new[] { "--out", "-o" }, + () => "reports", + "Output directory for the generated reports."); + var verbose = CommandOptions.Verbose(); + + var filename = new Option( + new[] { "--filename", "-n" }, + "Custom base filename for the reports. If omitted, a timestamped name is used."); + + var types = new Option( + new[] { "--types", "-t" }, + () => "both", + "Output types to include: 'csv', 'md', or 'both' (default).") + .FromAmong("csv", "md", "both"); + + cmd.AddOptions(project, config, output, verbose, filename, types); + + cmd.SetHandler(async (InvocationContext ctx) => + { + var projectPath = ctx.ParseResult.GetValueForOption(project); + var configPath = ctx.ParseResult.GetValueForOption(config); + var outDir = ctx.ParseResult.GetValueForOption(output)!; + var isVerbose = ctx.ParseResult.GetValueForOption(verbose); + var customFilename = ctx.ParseResult.GetValueForOption(filename); + var reportTypes = ctx.ParseResult.GetValueForOption(types)!; + + try + { + var appConfig = ConfigLoader.TryLoad(configPath); + var resolvedProject = ConfigLoader.ResolveProject(appConfig, projectPath); + + Out.Step("Building", $"project {Path.GetFileName(resolvedProject)}..."); + var builder = new ProjectBuilder(); + var buildResult = builder.Build(resolvedProject); + + Out.Step("Discovering", "plugin registrations from assembly..."); + var discovery = new PluginDiscovery(loggerFactory.CreateLogger()); + var definitions = discovery.Discover(buildResult.DllPath, isVerbose); + + Out.Info($"Found {definitions.Count} registration(s)."); + + Out.Step("Generating", "reports..."); + var generator = new ReportGenerator(); + + string baseName = !string.IsNullOrEmpty(customFilename) + ? customFilename + : $"plugin-report-{DateTime.Now:yyyyMMdd-HHmmss}"; + + if (!Directory.Exists(outDir)) + { + Directory.CreateDirectory(outDir); + } + + var generatedFiles = new List(); + + if (reportTypes is "md" or "both") + { + var md = generator.GenerateMarkdown(definitions); + var mdFile = Path.Combine(outDir, $"{baseName}.md"); + await File.WriteAllTextAsync(mdFile, md); + generatedFiles.Add(Path.GetFullPath(mdFile)); + } + + if (reportTypes is "csv" or "both") + { + var csv = generator.GenerateCsv(definitions); + var csvFile = Path.Combine(outDir, $"{baseName}.csv"); + await File.WriteAllTextAsync(csvFile, csv); + generatedFiles.Add(Path.GetFullPath(csvFile)); + } + + Out.Success("Reports generated:", string.Join("\n", generatedFiles)); + } + catch (Exception ex) + { + Out.Error(ex, isVerbose); + ctx.ExitCode = 1; + } + }); + + return cmd; + } + } +} diff --git a/src/dvx/Models/PluginStepDefinition.cs b/src/dvx/Models/PluginStepDefinition.cs index 958ff62..56f1a2e 100644 --- a/src/dvx/Models/PluginStepDefinition.cs +++ b/src/dvx/Models/PluginStepDefinition.cs @@ -2,11 +2,21 @@ namespace dvx.Models { public class PluginStepDefinition { + private int? _executionOrder; + public string TypeFullName { get; set; } = string.Empty; public string Entity { get; set; } = string.Empty; public string Message { get; set; } = string.Empty; public int Stage { get; set; } // 10 / 20 / 40 - public int ExecutionOrder { get; set; } = 1; + + public int ExecutionOrder + { + get => _executionOrder ?? 1; + set => _executionOrder = value; + } + + public bool IsExecutionOrderExplicit => _executionOrder.HasValue; + public int Mode { get; set; } // 0 = sync, 1 = async public string? Description { get; set; } /// diff --git a/src/dvx/Program.cs b/src/dvx/Program.cs index 80dbd5e..f79596c 100644 --- a/src/dvx/Program.cs +++ b/src/dvx/Program.cs @@ -11,6 +11,7 @@ plugin.AddCommand(RegisterCommand.Build(loggerFactory)); plugin.AddCommand(SyncCommand.Build(loggerFactory)); plugin.AddCommand(AdoptCommand.Build(loggerFactory)); +plugin.AddCommand(ReportCommand.Build(loggerFactory)); root.AddCommand(plugin); var webresource = new Command("webresource", "Deploy and publish Dataverse web resources."); diff --git a/src/dvx/Services/AttributeWriter.cs b/src/dvx/Services/AttributeWriter.cs index a62d5da..773c15d 100644 --- a/src/dvx/Services/AttributeWriter.cs +++ b/src/dvx/Services/AttributeWriter.cs @@ -297,8 +297,8 @@ internal static string RenderAttribute(PluginStepDefinition def) StageExpr(def.Stage), }; - if (def.ExecutionOrder != 1) args.Add($"ExecutionOrder = {def.ExecutionOrder}"); - if (def.Mode == 1) args.Add("Async = true"); + if (def.IsExecutionOrderExplicit) args.Add($"ExecutionOrder = {def.ExecutionOrder}"); + if (def.Mode == 1) args.Add("Async = true"); if (!string.IsNullOrEmpty(def.Description)) args.Add($"Description = {Lit(def.Description!)}"); if (def.RunAsUser is { } ru) diff --git a/src/dvx/Services/ReportGenerator.cs b/src/dvx/Services/ReportGenerator.cs new file mode 100644 index 0000000..62d0cb6 --- /dev/null +++ b/src/dvx/Services/ReportGenerator.cs @@ -0,0 +1,143 @@ +using System.Text; +using dvx.Models; +using dvx.Output; +using dvx.Utility; + +namespace dvx.Services +{ + public class ReportGenerator + { + public string GenerateMarkdown(IEnumerable definitions) + { + var md = new MarkdownBuilder(); + md.Heading(1, "Plugin Registration Report"); + + var list = definitions.ToList(); + + if (list.Any(d => !d.IsExecutionOrderExplicit)) + { + md.Quote("**⚠️ Warning:** All steps marked with an asterix (*) beside the Order number have no explicit execution order set. Their relative execution order within the same stage and mode is non-deterministic."); + } + + md.Heading(2, "By Entity then Message"); + RenderGroupedView(md, list, d => d.Entity, d => d.Message, "Entity", "Message"); + + md.HorizontalLine(); + + md.Heading(2, "By Message then Entity"); + RenderGroupedView(md, list, d => d.Message, d => d.Entity, "Message", "Entity"); + + return md.ToString(); + } + + private void RenderGroupedView( + MarkdownBuilder md, + List definitions, + Func primaryKey, + Func secondaryKey, + string primaryLabel, + string secondaryLabel) + { + var groups = definitions + .GroupBy(primaryKey) + .OrderBy(g => g.Key); + + foreach (var primaryGroup in groups) + { + md.Heading(3, $"{primaryLabel}: {primaryGroup.Key}"); + + var secondaryGroups = primaryGroup + .GroupBy(secondaryKey) + .OrderBy(g => g.Key); + + foreach (var secondaryGroup in secondaryGroups) + { + md.Heading(4, $"{secondaryLabel}: {secondaryGroup.Key}"); + + RenderStepTable(md, secondaryGroup); + } + } + } + + private void RenderStepTable(MarkdownBuilder md, IEnumerable steps) + { + var orderedSteps = steps + .OrderBy(d => d.Stage) + .ThenBy(d => d.Mode) + .ThenBy(d => d.ExecutionOrder) + .ToList(); + + var headers = new[] { "Stage", "Order", "Mode", "Plugin Type", "Description" }; + var rows = PrepareTableRows(orderedSteps); + + md.Table(headers, rows); + } + + private List PrepareTableRows(List steps) + { + var rows = new List(); + int? lastStage = null; + int? lastMode = null; + + foreach (var step in steps) + { + if ((lastStage.HasValue && lastStage.Value != step.Stage) || + (lastMode.HasValue && lastMode != step.Mode)) + { + rows.Add(["", "", "", "", ""]); // Add blank row between stages/steps + } + + var order = step.IsExecutionOrderExplicit ? step.ExecutionOrder.ToString() : $"{step.ExecutionOrder}*"; + var mode = step.Mode == 1 ? "Async" : "Sync"; + + rows.Add([ + PluginStepDefinition.StageName(step.Stage), + order, + mode, + step.TypeFullName, + step.Description ?? "-" + ]); + + lastStage = step.Stage; + lastMode = step.Mode; + } + + return rows; + } + + public string GenerateCsv(IEnumerable definitions) + { + var sb = new StringBuilder(); + sb.AppendLine("Type,Entity,Message,Stage,Mode,ExecutionOrder,IsExplicit,Description"); + + foreach (var def in definitions.OrderBy(d => d.Entity).ThenBy(d => d.Message).ThenBy(d => d.Mode) + .ThenBy(d => d.ExecutionOrder)) + { + var row = new[] + { + def.TypeFullName, + def.Entity, + def.Message, + PluginStepDefinition.StageName(def.Stage), + def.Mode == 1 ? "Async" : "Sync", + def.ExecutionOrder.ToString(), + def.IsExecutionOrderExplicit.ToString(), + def.Description ?? "" + }; + sb.AppendLine(string.Join(",", row.Select(EscapeCsv))); + } + + return sb.ToString(); + } + + private static string EscapeCsv(string value) + { + if (string.IsNullOrEmpty(value)) return ""; + if (value.Contains(",") || value.Contains("\"") || value.Contains("\n") || value.Contains("\r")) + { + return $"\"{value.Replace("\"", "\"\"")}\""; + } + return value; + } + } +} diff --git a/src/dvx/Services/StepImporter.cs b/src/dvx/Services/StepImporter.cs index ba5ca6b..1938637 100644 --- a/src/dvx/Services/StepImporter.cs +++ b/src/dvx/Services/StepImporter.cs @@ -83,13 +83,15 @@ public ImportResult Import(Guid assemblyId, bool verbose = false) Message = message, Stage = stage, Mode = step.GetAttributeValue("mode")?.Value ?? 0, - ExecutionOrder = step.GetAttributeValue("rank"), Description = NullIfEmpty(step.GetAttributeValue("description")), FilteringAttributes = SplitCsv(step.GetAttributeValue("filteringattributes")), Configuration = NullIfEmpty(step.GetAttributeValue("configuration")), RunAsUser = ResolveImpersonation(step.GetAttributeValue("impersonatinguserid"), meta.SystemUserId()), }; + var rank = step.GetAttributeValue("rank"); + if (rank != 1) def.ExecutionOrder = rank; + foreach (var img in LoadImages(step.Id)) { var isPre = (img.GetAttributeValue("imagetype")?.Value ?? 0) == 0; diff --git a/src/dvx/Utility/MarkdownBuilder.cs b/src/dvx/Utility/MarkdownBuilder.cs new file mode 100644 index 0000000..f6ef9cd --- /dev/null +++ b/src/dvx/Utility/MarkdownBuilder.cs @@ -0,0 +1,83 @@ +using System.Text; + +namespace dvx.Utility +{ + public class MarkdownBuilder + { + private readonly StringBuilder _sb = new(); + + public MarkdownBuilder Heading(int level, string text) + { + _sb.AppendLine($"{new string('#', level)} {text}"); + _sb.AppendLine(); + return this; + } + + public MarkdownBuilder Paragraph(string text, int indent = 0) + { + if (indent > 0) _sb.Append(new string(' ', indent * 2)); + _sb.AppendLine(text); + _sb.AppendLine(); + return this; + } + + public MarkdownBuilder Quote(string text) + { + _sb.AppendLine($"> {text}"); + _sb.AppendLine(); + return this; + } + + public MarkdownBuilder HorizontalLine() + { + _sb.AppendLine("---"); + _sb.AppendLine(); + return this; + } + + public MarkdownBuilder Table(string[] headers, IEnumerable rows) + { + var list = rows.ToList(); + var widths = new int[headers.Length]; + for (int i = 0; i < headers.Length; i++) + { + widths[i] = headers[i].Length; + foreach (var row in list) + { + if (row.Length > i) + widths[i] = Math.Max(widths[i], row[i].Length); + } + } + + RenderRow(headers, widths); + + _sb.Append("|"); + for (int i = 0; i < widths.Length; i++) + { + _sb.Append(" " + new string('-', widths[i]) + " |"); + } + _sb.AppendLine(); + + foreach (var row in list) + { + RenderRow(row, widths); + } + _sb.AppendLine(); + + return this; + } + + private void RenderRow(string[] cells, int[] widths) + { + _sb.Append("|"); + for (int i = 0; i < cells.Length; i++) + { + var val = i < cells.Length ? cells[i] : ""; + _sb.Append(" " + val.PadRight(widths[i]) + " |"); + } + _sb.AppendLine(); + } + + public override string ToString() => _sb.ToString(); + } +} diff --git a/src/dvx/dvx.csproj b/src/dvx/dvx.csproj index bebc0a1..d797e8f 100644 --- a/src/dvx/dvx.csproj +++ b/src/dvx/dvx.csproj @@ -17,8 +17,8 @@ https://github.com/beyro/dvx-cli package-icon.png MIT - 1.8.0: -- Added interactive login via `authType` in config file or `--interactive-auth` flag. + 1.9.0: +- New `report` command to generate a report of plugin registrations in Markdown and CSV