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
46 changes: 39 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,18 +122,41 @@ The project follows clean architecture principles with three main layers:

- Business logic and use case implementations
- Service implementations for core functionality:
- **Commands**: Browse, Info, New, Project commands
- **FileSystem**: File operations and copying services
- **GitHub**: GitHub repository and template handling
- **Project**: Project model management and policy enforcement
- **Data**: Repository pattern implementations with multiple store types
- **Utility**: Serialization services (JSON, YAML, Environment files)
- **Commands**: Browse, Info, New, Project commands
- **FileSystem**: File operations and copying services
- **GitHub**: GitHub repository and template handling
- **Project**: Project model management and policy enforcement
- **Data**: Repository pattern implementations with multiple store types
- **Utility**: Serialization services (JSON, YAML, Environment files)

#### Presentation Layer (`src/Keystone.Cli/Presentation/`)

- Command controllers using Cocona framework
- Decoupled from Application layer—can swap CLI frameworks without affecting business logic
- User interface and command-line interaction
- Controllers: `BrowseCommandController`, `InfoCommandController`, `NewCommandController`, `SubCommandsHostController`
- Controllers: `BrowseCommandController`, `InfoCommandController`, `NewCommandController`,
`SubCommandsHostController`

##### Cocona Conventions

Cocona exposes method parameters as CLI options by default. To keep help output clean:

- **Never** add `CancellationToken` as a command method parameter—it appears as
`--cancellation-token` in help output
- Instead, inject `ICoconaAppContextAccessor` via constructor and access the token from context:

```csharp
public class MyCommand(ICoconaAppContextAccessor contextAccessor)
{
public async Task<int> RunAsync()
{
var cancellationToken = contextAccessor.Current?.CancellationToken ?? CancellationToken.None;
// use cancellationToken...
}
}
```

This convention is enforced by `CoconaCommandMethodConventionsTests`.

### Key Components

Expand Down Expand Up @@ -166,6 +189,15 @@ Tests mirror the source structure in `tests/Keystone.Cli.UnitTests/`:
- Uses NUnit testing framework with NSubstitute for mocking
- Tests cover service implementations and command logic

#### Cocona Test Utilities (`Presentation/Cocona/`)

Framework-specific test infrastructure isolated from general test utilities:

- `CoconaAppContextFactory` — Creates `CoconaAppContext` instances for testing commands
that need `ICoconaAppContextAccessor`
- `CoconaCommandMethodConventionsTests` — Enforces conventions (e.g., no `CancellationToken`
parameters in command methods)

### Configuration

- Application settings in `appsettings.json` and `appsettings.Development.json`
Expand Down
11 changes: 9 additions & 2 deletions src/Keystone.Cli/Presentation/NewCommandController.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations;
using Cocona;
using Cocona.Application;
using Keystone.Cli.Application.Commands.New;
using Keystone.Cli.Application.Utility;
using Keystone.Cli.Domain;
Expand All @@ -12,7 +13,11 @@ namespace Keystone.Cli.Presentation;
/// <summary>
/// The "new" command controller.
/// </summary>
public class NewCommandController(IConsole console, INewCommand newCommand)
public class NewCommandController(
ICoconaAppContextAccessor contextAccessor,
IConsole console,
INewCommand newCommand
)
{
[Command("new", Description = "Creates a new project from a template")]
public async Task<int> NewAsync(
Expand All @@ -29,6 +34,8 @@ public async Task<int> NewAsync(
bool includeGitFiles = false
)
{
var cancellationToken = contextAccessor.Current?.CancellationToken ?? CancellationToken.None;

var fullPath = string.IsNullOrWhiteSpace(projectPath)
? Path.Combine(Path.GetFullPath("."), ProjectNamePolicy.GetProjectDirectoryName(projectName))
: Path.GetFullPath(projectPath);
Expand All @@ -40,7 +47,7 @@ await newCommand.CreateNewAsync(
templateName,
fullPath,
includeGitFiles,
CancellationToken.None
cancellationToken
);

return CliCommandResults.Success;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations;
using Cocona;
using Cocona.Application;
using Keystone.Cli.Application.Commands.Project;
using Keystone.Cli.Application.Utility;
using Keystone.Cli.Domain;
Expand All @@ -12,18 +13,23 @@ namespace Keystone.Cli.Presentation.Project;
/// <summary>
/// The implementation of the "switch-template" sub-command for the project command.
/// </summary>
public class SwitchTemplateSubCommand(IConsole console, IProjectCommand projectCommand)
public class SwitchTemplateSubCommand(
ICoconaAppContextAccessor contextAccessor,
IConsole console,
IProjectCommand projectCommand
)
{
public async Task<int> SwitchTemplateAsync(
[Argument(Description = "The name of the new template to switch to"),
NotPaddedWhitespace, Required(AllowEmptyStrings = false)]
string newTemplateName,
[Option(Description = "The path to the project where the template should be switched"),
Path, NotPaddedWhitespace]
string? projectPath = null,
CancellationToken cancellationToken = default
string? projectPath = null
)
{
var cancellationToken = contextAccessor.Current?.CancellationToken ?? CancellationToken.None;

var fullPath = string.IsNullOrWhiteSpace(projectPath)
? Path.GetFullPath(".")
: Path.GetFullPath(projectPath);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using System.Reflection;
using Cocona;
using Cocona.Command;


namespace Keystone.Cli.UnitTests.Presentation.Cocona;

/// <summary>
/// Factory for creating <see cref="CoconaAppContext"/> instances in tests.
/// </summary>
public static class CoconaAppContextFactory
{
/// <summary>
/// Creates a <see cref="CoconaAppContext"/> with the specified cancellation token.
/// </summary>
/// <param name="cancellationToken">The cancellation token to use.</param>
/// <returns>A new <see cref="CoconaAppContext"/> instance.</returns>
public static CoconaAppContext Create(CancellationToken cancellationToken = default)
{
var dummyMethod = typeof(CoconaAppContextFactory)
.GetMethod(nameof(DummyCommand), BindingFlags.NonPublic | BindingFlags.Static)!;

var commandDescriptor = new CommandDescriptor(
dummyMethod,
target: null,
name: "dummy",
aliases: [],
description: string.Empty,
metadata: [],
parameters: [],
options: [],
arguments: [],
overloads: [],
optionLikeCommands: [],
flags: CommandFlags.None,
subCommands: null
);

return new CoconaAppContext(commandDescriptor, cancellationToken);
}

private static void DummyCommand()
{
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
using System.Reflection;
using Cocona;
using Cocona.Application;
using Keystone.Cli.Presentation;


namespace Keystone.Cli.UnitTests.Presentation.Cocona;

[TestFixture, Parallelizable(ParallelScope.All)]
public class CoconaCommandMethodConventionsTests
{
private const string PresentationNamespace = "Keystone.Cli.Presentation";

/// <summary>
/// Cocona exposes method parameters as CLI options by default. <see cref="CancellationToken"/> parameters
/// should not appear in help output as they are framework infrastructure, not user-actionable arguments.
/// </summary>
/// <remarks>
/// To access <see cref="CancellationToken"/> in a command method, inject <see cref="ICoconaAppContextAccessor"/>
/// via the constructor and use its <see cref="ICoconaAppContextAccessor.Current"/> property,
/// e.g., <c>contextAccessor.Current?.CancellationToken</c>.
/// </remarks>
[Test]
public void CommandMethods_ShouldNotExposeCancellationTokenAsParameter()
{
MethodInfo[] commandMethods = [..DiscoverCommandMethods().Distinct()];

string[] violations =
[
..commandMethods.SelectMany(method => method
.GetParameters()
.Where(p => p.ParameterType == typeof(CancellationToken))
.Select(p => $"{method.DeclaringType!.Name}.{method.Name}({p.Name})")
),
];

Assert.That(
violations,
Is.Empty,
$"""
{nameof(CancellationToken)} parameters are exposed as CLI options.
Use {nameof(ICoconaAppContextAccessor)} instead.

Violations:
{string.Join(Environment.NewLine, violations.Select(violation => $"- {violation}"))}
"""
);
}

private static IEnumerable<MethodInfo> DiscoverCommandMethods()
{
Type[] controllerTypes =
[
..typeof(BrowseCommandController).Assembly
.GetTypes()
.Where(t => t.Namespace?.StartsWith(PresentationNamespace) == true)
.Where(t => t is { IsClass: true, IsPublic: true } && t.Name.EndsWith("Controller")),
];

foreach (var controllerType in controllerTypes)
{
// Methods with [Command] attribute
foreach (var method in GetMethodsWithCommandAttribute(controllerType))
{
yield return method;
}

// Recursively resolve [HasSubCommands] targets
foreach (var method in GetSubCommandMethods(controllerType))
{
yield return method;
}
}
}

private static IEnumerable<MethodInfo> GetMethodsWithCommandAttribute(Type type)
=> type
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
.Where(m => m.GetCustomAttribute<CommandAttribute>() != null);

private static IEnumerable<MethodInfo> GetSubCommandMethods(Type type)
{
foreach (var attr in type.GetCustomAttributes<HasSubCommandsAttribute>())
{
var subCommandType = attr.Type;

// Get public *Async methods from subcommand types
var asyncMethods = subCommandType
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
.Where(m => m.Name.EndsWith("Async"));

foreach (var method in asyncMethods)
{
yield return method;
}

// Recurse into nested [HasSubCommands]
foreach (var method in GetSubCommandMethods(subCommandType))
{
yield return method;
}
}
}
}
Loading