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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,12 @@ Run `bld` from any git repository that should host the Build submodule.

A bare `bld` (no command) runs `build` after verifying that the Build submodule is initialized. If it is missing, the CLI stops and tells you to run `bld init`.

Any unknown command is forwarded to the Build host as a Cake target, so `bld Test` is the same as `bld build Test`. Extra Cake arguments are passed through; `bld`'s own options (`--path`, `--configuration`, and so on) are still parsed by the CLI.

```text
bld Run the Default Cake target (same as `bld build`)
bld Test Run the Cake Test target (unknown commands are forwarded)
bld Publish --publish --nuget Run a Cake target and pass extra host arguments
bld init Add the Build submodule (defaults to the latest tag)
bld init --tag v1.2.3 Add the Build submodule pinned to a specific tag
bld update Move an existing submodule to the latest tag
Expand Down Expand Up @@ -47,7 +51,7 @@ Existing Ingenium repositories that already use `build` or `Build` as the submod

`repair` can prompt for a strategy when run interactively. `reset` and `reinit` are destructive and require `--yes` in non-interactive use.

`build` first checks that the Build submodule is registered and checked out. It then restores .NET local tools when `.config/dotnet-tools.json` is present, and runs `apps/Build` the same way `./build.sh` does.
`build` first checks that the Build submodule is registered and checked out. It then restores .NET local tools when `.config/dotnet-tools.json` is present, and runs `apps/Build` the same way `./build.sh` does. Unknown commands such as `Test`, `Pack`, or a build-extension task name are forwarded to that host as `--target` values.

`extension` writes the layout the Build host already imports:

Expand Down
6 changes: 3 additions & 3 deletions apps/Ingenium.BuildCli/BuildCliApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,10 @@ public static void Configure(IConfigurator config)
.WithExample("repair", "--strategy", "reinit", "--tag", "v1.2.3", "--yes");

config.AddCommand<BuildCommand>("build")
.WithDescription("Run a Cake target through the Build submodule. This is the default when no command is passed.")
.WithDescription("Run a Cake target through the Build submodule. This is the default when no command is passed, and unknown commands are forwarded here.")
.WithExample("build")
.WithExample("build", "TestProjects")
.WithExample("build", "Default", "--configuration", "Release");
.WithExample("build", "Test")
.WithExample("build", "Publish", "--", "--publish", "--nuget");

config.AddCommand<ExtensionCommand>("extension")
.WithDescription("Create a Cake build-extension project in build-extensions/.")
Expand Down
143 changes: 134 additions & 9 deletions apps/Ingenium.BuildCli/CommandLineDefaults.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
namespace Ingenium.BuildCli;

/// <summary>
/// Applies default command routing so a bare invocation runs <c>build</c>.
/// Applies default command routing so a bare or unknown invocation runs the Build host.
/// </summary>
public static class CommandLineDefaults
{
Expand Down Expand Up @@ -32,8 +32,41 @@ public static class CommandLineDefaults
"--version"
};

private static readonly HashSet<string> CliValueOptions = new(StringComparer.OrdinalIgnoreCase)
{
"-p",
"--path",
"--submodule-path",
"--url",
"-c",
"--configuration",
"-t",
"--tag",
"-s",
"--strategy"
};

private static readonly HashSet<string> CliFlagOptions = new(StringComparer.OrdinalIgnoreCase)
{
"--https",
"--verbose",
"-f",
"--force",
"-y",
"--yes"
};

/// <summary>
/// Returns <c>true</c> when <paramref name="name"/> is a first-class <c>bld</c> command.
/// </summary>
public static bool IsKnownCommand(string? name)
{
return !string.IsNullOrWhiteSpace(name) && Commands.Contains(name);
}

/// <summary>
/// Inserts <c>build</c> when no command was supplied.
/// Inserts <c>build</c> when no command was supplied, and forwards unknown commands
/// to the Build submodule as Cake targets.
/// </summary>
public static string[] Apply(IReadOnlyList<string> args)
{
Expand All @@ -45,23 +78,115 @@ public static string[] Apply(IReadOnlyList<string> args)
}

var first = args[0];
if (MetaOptions.Contains(first) || Commands.Contains(first))
if (MetaOptions.Contains(first))
{
return ToArray(args);
}

if (first.Equals(DefaultCommand, StringComparison.OrdinalIgnoreCase))
{
return args as string[] ?? args.ToArray();
return NormalizeBuildArguments(args);
}

if (Commands.Contains(first))
{
return ToArray(args);
}

if (first.StartsWith('-'))
{
var routed = new string[args.Count + 1];
routed[0] = DefaultCommand;
for (var i = 0; i < args.Count; i++)
return Prepend(DefaultCommand, args);
}

return NormalizeBuildArguments(Prepend(DefaultCommand, args));
}

private static string[] NormalizeBuildArguments(IReadOnlyList<string> args)
{
var result = new List<string> { DefaultCommand };
var index = 1;

if (index < args.Count && !args[index].StartsWith('-'))
{
result.Add(args[index]);
index++;
}

var ours = new List<string>();
var cake = new List<string>();
SplitCliAndCakeArguments(args, index, ours, cake);

result.AddRange(ours);
if (cake.Count > 0)
{
result.Add("--");
result.AddRange(cake);
}

return result.ToArray();
}

private static void SplitCliAndCakeArguments(
IReadOnlyList<string> args,
int startIndex,
List<string> cliArguments,
List<string> cakeArguments)
{
for (var i = startIndex; i < args.Count; i++)
{
var arg = args[i];
if (arg == "--")
{
for (var j = i + 1; j < args.Count; j++)
{
cakeArguments.Add(args[j]);
}

return;
}

var name = OptionName(arg);
if (CliFlagOptions.Contains(name))
{
cliArguments.Add(arg);
continue;
}

if (CliValueOptions.Contains(name))
{
routed[i + 1] = args[i];
cliArguments.Add(arg);
if (!arg.Contains('=') && i + 1 < args.Count)
{
cliArguments.Add(args[++i]);
}

continue;
}

return routed;
cakeArguments.Add(arg);
}
}

private static string OptionName(string argument)
{
var equals = argument.IndexOf('=');
return equals < 0 ? argument : argument[..equals];
}

private static string[] Prepend(string command, IReadOnlyList<string> args)
{
var routed = new string[args.Count + 1];
routed[0] = command;
for (var i = 0; i < args.Count; i++)
{
routed[i + 1] = args[i];
}

return routed;
}

private static string[] ToArray(IReadOnlyList<string> args)
{
return args as string[] ?? args.ToArray();
}
}
15 changes: 15 additions & 0 deletions tests/Ingenium.BuildCli.Tests/CommandAppTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,21 @@ public async Task InitThenStatus_ShowsCurrentTag()
Assert.Contains("v1.0.0", console.Output);
}

[Fact]
public async Task UnknownCommand_ForwardsToBuildTarget()
{
using var workspace = GitTestWorkspace.Create();
var console = new TestConsole();
var exitCode = await BuildCliApplication.RunAsync(
["Test", "--path", workspace.ParentRepo, "--url", workspace.BuildRepo],
console,
services => services.AddSingleton<IGitClient>(_ => GitTestWorkspace.CreateClient()));

Assert.Equal(ExitCodes.SubmoduleNotFound, exitCode);
Assert.Contains("bld init", console.Output, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("Unknown command", console.Output, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public async Task NoCommand_DefaultsToBuild_AndRequiresInitializedSubmodule()
{
Expand Down
30 changes: 30 additions & 0 deletions tests/Ingenium.BuildCli.Tests/CommandLineDefaultsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,34 @@ public void Apply_TreatsLeadingOptionsAsBuildOptions()
["build", "--path", "./src", "--configuration", "Release"],
CommandLineDefaults.Apply(["--path", "./src", "--configuration", "Release"]));
}

[Fact]
public void Apply_ForwardsUnknownCommandAsCakeTarget()
{
Assert.Equal(["build", "Test"], CommandLineDefaults.Apply(["Test"]));
Assert.Equal(["build", "Publish"], CommandLineDefaults.Apply(["Publish"]));
}

[Fact]
public void Apply_KeepsCliOptionsAndForwardsCakeArguments()
{
Assert.Equal(
["build", "Publish", "--path", "./src", "--", "--publish", "--nuget", "--token", "abc"],
CommandLineDefaults.Apply(["Publish", "--path", "./src", "--publish", "--nuget", "--token", "abc"]));
}

[Fact]
public void Apply_NormalizesExplicitBuildCommandCakeArguments()
{
Assert.Equal(
["build", "Test", "--configuration", "Release", "--", "--verbosity", "Diagnostic"],
CommandLineDefaults.Apply(["build", "Test", "--configuration", "Release", "--verbosity", "Diagnostic"]));
}

[Fact]
public void IsKnownCommand_RecognizesFirstClassCommands()
{
Assert.True(CommandLineDefaults.IsKnownCommand("init"));
Assert.False(CommandLineDefaults.IsKnownCommand("Test"));
}
}
Loading