Skip to content
49 changes: 49 additions & 0 deletions src/dvx.Tests/MarkdownBuilderTests.cs
Original file line number Diff line number Diff line change
@@ -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<string[]> { new[] { "Short", "Val" } };

Check warning on line 39 in src/dvx.Tests/MarkdownBuilderTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer 'static readonly' fields over constant array arguments if the called method is called repeatedly and is not mutating the passed array

See more on https://sonarcloud.io/project/issues?id=beyro_dvx-cli&issues=AZ82TBJqhgu0ReJSYGZ4&open=AZ82TBJqhgu0ReJSYGZ4&pullRequest=16

md.Table(headers, rows);
var result = md.ToString();

result.ShouldContain("| A | Long Header |");
result.ShouldContain("| ----- | ----------- |");
result.ShouldContain("| Short | Val |");
}
}
}
34 changes: 34 additions & 0 deletions src/dvx.Tests/PluginDiscoveryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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();
}
}
}
20 changes: 20 additions & 0 deletions src/dvx.Tests/PluginStepDefinitionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
}
138 changes: 138 additions & 0 deletions src/dvx.Tests/ReportGeneratorTests.cs
Original file line number Diff line number Diff line change
@@ -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<PluginStepDefinition>
{
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<PluginStepDefinition>
{
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<PluginStepDefinition>
{
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<PluginStepDefinition>
{
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<PluginStepDefinition>
{
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<PluginStepDefinition>
{
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\"");
}
}
}
108 changes: 108 additions & 0 deletions src/dvx/Commands/ReportCommand.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// <c>dvx plugin report</c> — 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.
/// </summary>
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<string>(
new[] { "--out", "-o" },

Check warning on line 25 in src/dvx/Commands/ReportCommand.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer 'static readonly' fields over constant array arguments if the called method is called repeatedly and is not mutating the passed array

See more on https://sonarcloud.io/project/issues?id=beyro_dvx-cli&issues=AZ82TBN3hgu0ReJSYGaB&open=AZ82TBN3hgu0ReJSYGaB&pullRequest=16
() => "reports",
"Output directory for the generated reports.");
var verbose = CommandOptions.Verbose();

var filename = new Option<string?>(
new[] { "--filename", "-n" },

Check warning on line 31 in src/dvx/Commands/ReportCommand.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer 'static readonly' fields over constant array arguments if the called method is called repeatedly and is not mutating the passed array

See more on https://sonarcloud.io/project/issues?id=beyro_dvx-cli&issues=AZ82TBN3hgu0ReJSYGaC&open=AZ82TBN3hgu0ReJSYGaC&pullRequest=16
"Custom base filename for the reports. If omitted, a timestamped name is used.");

var types = new Option<string>(
new[] { "--types", "-t" },

Check warning on line 35 in src/dvx/Commands/ReportCommand.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer 'static readonly' fields over constant array arguments if the called method is called repeatedly and is not mutating the passed array

See more on https://sonarcloud.io/project/issues?id=beyro_dvx-cli&issues=AZ82TBN3hgu0ReJSYGaD&open=AZ82TBN3hgu0ReJSYGaD&pullRequest=16
() => "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<PluginDiscovery>());
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<string>();

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;
}
}
}
12 changes: 11 additions & 1 deletion src/dvx/Models/PluginStepDefinition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
/// <summary>
Expand Down
Loading
Loading