Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ModularPipelines.Attributes;
using ModularPipelines.OptionsGenerator.Models;
Expand Down Expand Up @@ -90,7 +91,8 @@ fake sibling [flags]
--value string Supply a value
""",
});
var scraper = new TestCobraScraper(executor);
var logger = new RecordingLogger();
var scraper = new TestCobraScraper(executor, logger);

await Assert.That(scraper.DeclaresCommandGroup(emptyGroupHelp)).IsTrue();
await Assert.That(scraper.GetSubcommands(emptyGroupHelp)).IsEmpty();
Expand All @@ -101,6 +103,25 @@ await Assert.That(commands.Select(command => command.FullCommand))
.IsEquivalentTo(["fake sibling"]);
await Assert.That(executor.Arguments)
.IsEquivalentTo(["--help", "parent --help", "sibling --help"]);
await Assert.That(logger.Warnings).Contains(warning =>
warning.Exception is InvalidOperationException
&& warning.Message.Contains("Failed to validate subcommand discovery: fake parent"));
}

[Test]
public async Task SharedTraversal_Detects_Command_On_Second_Usage_Line()
{
const string helpText = """
Usage:
fake [flags]
fake <command> [flags]

Available Commands:
""";
var scraper = new TestCobraScraper(new StubExecutor(
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)));

await Assert.That(scraper.DeclaresCommandGroup(helpText)).IsTrue();
}

[Test]
Expand Down Expand Up @@ -409,11 +430,11 @@ private static async Task<IReadOnlyList<CliCommandDefinition>> ScrapeAsync(ICliS

private sealed class TestCobraScraper : CobraCliScraper
{
public TestCobraScraper(ICliCommandExecutor executor)
public TestCobraScraper(ICliCommandExecutor executor, ILogger? logger = null)
: base(
executor,
new HelpTextCache(NullLogger<HelpTextCache>.Instance),
NullLogger<TestCobraScraper>.Instance)
logger ?? NullLogger<TestCobraScraper>.Instance)
{
}

Expand All @@ -438,6 +459,29 @@ public TestCobraScraper(ICliCommandExecutor executor)
public IReadOnlyList<string> GetSubcommands(string helpText) => ExtractSubcommands(helpText).ToList();
}

private sealed class RecordingLogger : ILogger
{
public List<(string Message, Exception? Exception)> Warnings { get; } = [];

public IDisposable? BeginScope<TState>(TState state)
where TState : notnull => null;

public bool IsEnabled(LogLevel logLevel) => true;

public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
if (logLevel == LogLevel.Warning)
{
Warnings.Add((formatter(state, exception), exception));
}
}
}

private sealed class TestPodmanCliScraper(ICliCommandExecutor executor)
: PodmanCliScraper(
executor,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,54 @@ await Assert.That(subcommands).IsEquivalentTo(
["attest", "sign", "verify", "verify-blob-attestation"]);
}

[Test]
public async Task Extracts_Cosign_V3_All_Word_Command_Table()
{
const string helpText = """
Tools for interacting with a Sigstore protobuf bundle

Usage:
cosign bundle [command]

Available Commands:
create Create a Sigstore protobuf bundle
inspect Inspect a Sigstore protobuf bundle
upgrade Upgrade a Sigstore protobuf bundle

Flags:
-h, --help=false:
""";

var subcommands = new TestCosignCliScraper().Extract(helpText);

await Assert.That(subcommands).IsEquivalentTo(["create", "inspect", "upgrade"]);
}

[Test]
[Arguments("signing-config", "signing config")]
[Arguments("trusted-root", "trusted root")]
public async Task Extracts_Cosign_V3_Single_Row_All_Word_Command_Tables(
string commandGroup,
string description)
{
var helpText = $"""
Tools for interacting with a Sigstore protobuf {description}

Usage:
cosign {commandGroup} [command]

Available Commands:
create Create a Sigstore protobuf {description}

Flags:
-h, --help=false:
""";

var subcommands = new TestCosignCliScraper().Extract(helpText);

await Assert.That(subcommands).IsEquivalentTo(["create"]);
}

[Test]
public async Task Parses_Cosign_V3_Default_Value_Flag_Format()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ namespace ModularPipelines.OptionsGenerator.Tests.Scrapers;

public class PulumiCliScraperTests
{
[Test]
public async Task Env_Run_Command_Operand_Is_Not_A_Command_Group()
{
const string helpText = """
Run a command within an environment.

Usage:
pulumi env run <environment-name> -- <command> [args]

Run a command
The command receives the environment variables.
""";

await Assert.That(new TestPulumiCliScraper().DeclaresCommandGroup(helpText)).IsFalse();
}

[Test]
public async Task Env_Get_Preserves_Required_Environment_And_Optional_Path()
{
Expand Down Expand Up @@ -91,5 +107,7 @@ public TestPulumiCliScraper()
var usage = ParseUsageSynopsis(commandPath, helpText);
return ParseCommandAsync(commandPath, helpText, usage, CancellationToken.None);
}

public bool DeclaresCommandGroup(string helpText) => HelpDeclaresCommandGroup(helpText);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,18 +56,26 @@ public async Task Sbom_Help_Exposes_Test_Subcommand()
}

[Test]
public async Task Command_Help_Does_Not_Treat_Examples_As_Subcommands()
public async Task Command_Help_Does_Not_Treat_Examples_As_A_Command_Group()
{
const string helpText = """
Test a project for vulnerabilities.

Test
Usage
snyk test [<OPTIONS>]

See code test, container test, and iac test for related commands.
Options for build tools
The format is snyk <command> -- [<context-specific_options>]

Examples for the snyk test command
$ snyk test
""";

await Assert.That(new TestSnykCliScraper().Extract(helpText)).IsEmpty();
var scraper = new TestSnykCliScraper();
using (Assert.Multiple())
{
await Assert.That(scraper.Extract(helpText)).IsEmpty();
await Assert.That(scraper.DeclaresCommandGroup(helpText)).IsFalse();
}
}

[Test]
Expand Down Expand Up @@ -396,6 +404,8 @@ public TestSnykCliScraper()

public IReadOnlyList<string> Extract(string helpText) => ExtractSubcommands(helpText).ToList();

public bool DeclaresCommandGroup(string helpText) => HelpDeclaresCommandGroup(helpText);

public bool CanGenerate(string helpText) => HasOptions(helpText);

public Task<CliCommandDefinition?> Parse(string[] commandPath, string helpText) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -997,11 +997,11 @@ private static void ValidateArgumentGroups(CliCommandDefinition command)
protected static partial Regex OptionLinePattern();

[GeneratedRegex(
@"^[ \t]*(?:Usage:?[ \t]*(?:\r?\n[ \t]*)?)?[^\r\n]*(?:<command>|\[command\])[^\r\n]*\r?$",
@"^[ \t]*Usage:?[ \t]*(?:[^\r\n]*\r?\n[ \t]*){0,2}[^\r\n]*(?:<command>|\[command\])[^\r\n]*\r?$",
RegexOptions.IgnoreCase | RegexOptions.Multiline)]
private static partial Regex CommandGroupUsagePattern();

[GeneratedRegex(@"^[ \t]*[A-Z][A-Z0-9 _/-]*COMMANDS?:?[ \t]*\r?$", RegexOptions.IgnoreCase | RegexOptions.Multiline)]
[GeneratedRegex(@"^[ \t]*[A-Z][A-Z0-9 _/-]*COMMANDS:?[ \t]*\r?$", RegexOptions.IgnoreCase | RegexOptions.Multiline)]
private static partial Regex CommandSectionHeadingPattern();

[GeneratedRegex(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -945,7 +945,7 @@ private static string DetermineCSharpType(
/// Matches section headers like "Flags:", "Usage:", etc.
/// </summary>
[GeneratedRegex(
@"^(?:[A-Z][\w\s]*:|[A-Z][\w ]*(?:Commands|Flags|Options|Usage|Examples))\s*$",
@"^(?:[A-Z][\w \t]*:|[A-Z][\w ]*(?:Commands|Flags|Options|Usage|Examples))\s*$",
RegexOptions.IgnoreCase | RegexOptions.Multiline)]
private static partial Regex SectionHeaderPattern();

Expand Down
Loading