diff --git a/.github/instructions/plugins.instructions.md b/.github/instructions/plugins.instructions.md index 940f099..540a751 100644 --- a/.github/instructions/plugins.instructions.md +++ b/.github/instructions/plugins.instructions.md @@ -56,12 +56,37 @@ public sealed class MyFeaturePlugin : IPlugin | Param | Meaning | |-------|---------| | `Command` | The `System.CommandLine.Command` instance (from `MyCommand.Create()`) | -| `ParentCommand` | `null` = root level, `"source"`/`"generate"`/etc. = subcommand. Parent created automatically if missing. | +| `ParentCommand` | `null` = root level, `"source"`/`"generate"`/etc. = subcommand. Parent created automatically if missing. **Multi-level paths supported** (`"info plugins"` → `revela info plugins `). | | `Order` | Sort order within parent (default 50; lower = earlier) | | `Group` | Display group label in interactive menu | | `RequiresProject` | `false` = available without `project.json` (e.g. `init`, `setup`) | | `HideWhenProjectExists` | `true` = hidden inside a project (e.g. setup wizards) | | `IsSequentialStep` | `true` = picked up by CLI `generate all` discovery. Pair with `IPipelineStep` for engine/MCP. | +| `InlineInMenu` | Host-only flag (`info` command tree). Plugins should not need this. | +| `InlineDefaultActionLabel` | Required when `InlineInMenu = true`. Plugins should not need this. | + +## `info` Subcommands — Convention for Plugins +Plugins **may** contribute one read-only diagnostic subcommand under +`revela info plugins ` by registering with +`ParentCommand: "info plugins"`. This is opt-in; nothing breaks if you skip it. + +```csharp +yield return new CommandDescriptor( + myInfoCommand.Create(), + ParentCommand: "info plugins", + Order: 10); +``` + +Hard rules for `info` subcommands: +- **Read-only.** No prompts, no writes, no network calls that mutate state. +- **Compact.** Output sized for bug-report copy-paste — typically a single + Spectre `Panel` with key/value lines. No tables that scroll. +- **Fast.** No long-running work; user expects a tap-and-read response. +- **Safe without context.** Must not crash when invoked without an active + project (e.g. report "no project loaded" instead of throwing). +- **No side effects on cache, auth, or files.** This is diagnostics, not + troubleshooting tooling. Use a dedicated `doctor` or `check` command if + you need active probing. ## Plugin Configuration 1. Create config class with `[RevelaConfig("Spectara.Revela.Plugins.MyFeature")]` — full package ID is the JSON section name. diff --git a/CHANGELOG.md b/CHANGELOG.md index 68e3c7a..6085d9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`revela info` command tree** — new diagnostic command tree exposing version data and installed packages. `revela info` (default action) prints a Revela summary panel; `revela info plugins` lists all installed plugins with version and source; `revela info themes` lists themes with the active one marked. Plugins/themes can contribute per-package detail subcommands by registering with `ParentCommand: "info plugins"` / `"info themes"` (read-only diagnostic convention documented in `plugins.instructions.md`). ([#23](https://github.com/Spectara/Revela/issues/23)) +- **`IBuildInfo` SDK service** — new `Spectara.Revela.Sdk.Hosting.IBuildInfo` interface (with `HostKind` enum: `Standalone` / `Embedded`) exposes the immutable build-time identity of the running host. Single source of truth for `--version`, `revela info`, and any plugin that needs to branch on host kind (e.g. self-update plugins hiding themselves in embedded builds). Detected via `Revela.HostKind` assembly metadata attribute set in `Cli.Embedded.csproj` — sidesteps the `AssemblyName="revela"` collision that makes name-based detection impossible. ([#23](https://github.com/Spectara/Revela/issues/23)) +- **`CommandDescriptor.InlineInMenu` + `InlineDefaultActionLabel`** — opt-in fields that flatten a parent command's inline appearance in the interactive menu while leaving CLI behavior unchanged. Used by `revela info` to render `Info → Revela / Plugins → / Themes →` instead of `Info → info → …`. Default `false`, zero impact on existing descriptors. ([#23](https://github.com/Spectara/Revela/issues/23)) + +### Changed + +- **`revela --version` is now human-readable and host-kind aware** — the System.CommandLine default action is replaced with a renderer that prints `revela 1.0.0 (.NET 10.0.7)` (or `… — embedded build` for the embedded variant). Identical to the first line of `revela info`, so both surfaces report the same identifier. ([#23](https://github.com/Spectara/Revela/issues/23)) +- **Welcome panel slimmed down** — the ASCII-art Revela logo is removed from interactive startup (it was off-brand vs. the actual aperture wordmark and added ~6 lines of friction on every menu render). The welcome panel header is now `Revela`; the redundant `Version` line and the `Modern static site generator for photographers` tagline are gone. Version data lives in `revela info` (canonical for both CLI and TUI users). The first-run panel is unchanged. ([#23](https://github.com/Spectara/Revela/issues/23)) + +### Fixed + +- **Inlined-parent menu entries dispatched to the wrong command** — clicking an inlined subcommand in the interactive main menu (e.g. `Plugins` under `Info`) built the args path from the leaf name only (`["plugins"]`) instead of the absolute path (`["info","plugins"]`), producing an `Unrecognized command or argument` error from System.CommandLine. The top-level menu now routes `Navigate`/`Execute` selections through the same dispatcher as nested menus, honoring `MenuChoice.CommandPathOverride`. ([#23](https://github.com/Spectara/Revela/issues/23)) + ## [0.0.1-beta.20] - 2026-05-06 ### Fixed diff --git a/Spectara.Revela.slnx b/Spectara.Revela.slnx index 092957f..6621a6f 100644 --- a/Spectara.Revela.slnx +++ b/Spectara.Revela.slnx @@ -33,6 +33,7 @@ + diff --git a/src/Cli.Embedded/Cli.Embedded.csproj b/src/Cli.Embedded/Cli.Embedded.csproj index af23f2e..00b9ec3 100644 --- a/src/Cli.Embedded/Cli.Embedded.csproj +++ b/src/Cli.Embedded/Cli.Embedded.csproj @@ -20,6 +20,13 @@ + + + + + diff --git a/src/Cli/Hosting/BuildInfo.cs b/src/Cli/Hosting/BuildInfo.cs new file mode 100644 index 0000000..463a426 --- /dev/null +++ b/src/Cli/Hosting/BuildInfo.cs @@ -0,0 +1,142 @@ +using System.Globalization; +using System.Reflection; +using System.Runtime.InteropServices; + +using Spectara.Revela.Sdk.Hosting; + +namespace Spectara.Revela.Cli.Hosting; + +/// +/// Default implementation. +/// +/// +/// +/// Detects by reading the Revela.HostKind +/// from the entry assembly. Default +/// when the attribute is absent is . +/// +/// +/// Both Cli and Cli.Embedded produce an executable named revela, so +/// assembly-name-based detection is impossible. The metadata attribute is the +/// single source of truth, set in Cli.Embedded.csproj via: +/// +/// +/// <ItemGroup> +/// <AssemblyMetadata Include="Revela.HostKind" Value="Embedded" /> +/// </ItemGroup> +/// +/// +internal sealed class BuildInfo : IBuildInfo +{ + private const string HostKindMetadataKey = "Revela.HostKind"; + + private const string BuildConfiguration = +#if DEBUG + "Debug"; +#else + "Release"; +#endif + + public BuildInfo() + : this(Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly()) + { + } + + /// + /// Test seam: construct from a specific assembly, bypassing entry-assembly + /// auto-detection. Used by BuildInfoTests. + /// + internal BuildInfo(Assembly entry) + { + Kind = DetectHostKind(entry); + InformationalVersion = DetectInformationalVersion(entry); + Version = StripBuildMetadata(InformationalVersion); + Framework = RuntimeInformation.FrameworkDescription; + Configuration = BuildConfiguration; + RuntimeIdentifier = RuntimeInformation.RuntimeIdentifier; + } + + /// + /// Test seam: construct directly from raw values, fully bypassing assembly + /// inspection. Used by BuildInfoTests for FormatVersionLine assertions + /// across both values. + /// + internal BuildInfo( + HostKind kind, + string version, + string informationalVersion, + string framework, + string configuration, + string runtimeIdentifier) + { + Kind = kind; + Version = version; + InformationalVersion = informationalVersion; + Framework = framework; + Configuration = configuration; + RuntimeIdentifier = runtimeIdentifier; + } + + public HostKind Kind { get; } + + public string Version { get; } + + public string InformationalVersion { get; } + + public string Framework { get; } + + public string Configuration { get; } + + public string RuntimeIdentifier { get; } + + public string FormatVersionLine() + { + var suffix = Kind switch + { + HostKind.Embedded => " \u2014 embedded build", + HostKind.Standalone => string.Empty, + _ => string.Empty, + }; + return string.Create( + CultureInfo.InvariantCulture, + $"revela {Version} ({Framework}){suffix}"); + } + + private static HostKind DetectHostKind(Assembly entry) + { + var value = entry + .GetCustomAttributes() + .FirstOrDefault(a => string.Equals(a.Key, HostKindMetadataKey, StringComparison.Ordinal)) + ?.Value; + + return string.Equals(value, nameof(HostKind.Embedded), StringComparison.Ordinal) + ? HostKind.Embedded + : HostKind.Standalone; + } + + /// Test seam — exposes . + internal static HostKind DetectHostKindForTesting(Assembly entry) => DetectHostKind(entry); + + private static string DetectInformationalVersion(Assembly entry) + { + var info = entry.GetCustomAttribute()?.InformationalVersion; + if (!string.IsNullOrWhiteSpace(info)) + { + return info; + } + + var version = entry.GetName().Version; + return version is null + ? "0.0.0" + : version.ToString(3); + } + + private static string StripBuildMetadata(string informational) + { + var plus = informational.IndexOf('+', StringComparison.Ordinal); + return plus < 0 ? informational : informational[..plus]; + } + + /// Test seam — exposes . + internal static string StripBuildMetadataForTesting(string informational) => StripBuildMetadata(informational); +} diff --git a/src/Cli/Hosting/CommandGroupRegistry.cs b/src/Cli/Hosting/CommandGroupRegistry.cs index 7718683..03ce378 100644 --- a/src/Cli/Hosting/CommandGroupRegistry.cs +++ b/src/Cli/Hosting/CommandGroupRegistry.cs @@ -19,6 +19,9 @@ internal static class CommandGroups /// Plugin addons and optional features. public const string Addons = "Addons"; + /// Diagnostic / about commands (main menu, bottom). + public const string Info = "Info"; + // Config submenu groups /// Core project configuration commands (config submenu). diff --git a/src/Cli/Hosting/CommandOrderRegistry.cs b/src/Cli/Hosting/CommandOrderRegistry.cs index b2d1c30..22d9d04 100644 --- a/src/Cli/Hosting/CommandOrderRegistry.cs +++ b/src/Cli/Hosting/CommandOrderRegistry.cs @@ -24,6 +24,7 @@ internal sealed class CommandOrderRegistry private readonly HashSet noProjectRequired = []; private readonly HashSet hideWhenProjectExists = []; private readonly HashSet pipelineSteps = []; + private readonly Dictionary inlinedParents = []; /// /// Registers the display order for a command. @@ -86,6 +87,33 @@ internal sealed class CommandOrderRegistry /// True if the command is a pipeline step. public bool IsPipelineStep(Command command) => pipelineSteps.Contains(command); + /// + /// Marks a command as an inlined parent for the interactive menu. + /// + /// + /// Inlined parents are not rendered as a single navigable entry. Instead + /// the menu renders a virtual entry for the command's default action + /// (labeled by ) and lists the + /// command's visible subcommands directly under the same group label. + /// CLI behavior is unaffected. + /// + /// The parent command to render inline. + /// Display label for the virtual default-action entry. + public void RegisterInlinedParent(Command command, string defaultActionLabel) => + inlinedParents[command] = defaultActionLabel; + + /// + /// Gets whether a command is registered as an inlined parent. + /// + public bool IsInlinedParent(Command command) => inlinedParents.ContainsKey(command); + + /// + /// Gets the display label for an inlined parent's default-action entry, + /// or null if the command is not registered as inlined. + /// + public string? GetInlineDefaultActionLabel(Command command) => + inlinedParents.TryGetValue(command, out var label) ? label : null; + /// /// Gets the display order for a command. /// diff --git a/src/Cli/Hosting/ConsoleUI.cs b/src/Cli/Hosting/ConsoleUI.cs index eb3de7e..00729af 100644 --- a/src/Cli/Hosting/ConsoleUI.cs +++ b/src/Cli/Hosting/ConsoleUI.cs @@ -1,5 +1,3 @@ -using System.Reflection; - using Spectara.Revela.Sdk; using Spectre.Console; @@ -30,68 +28,42 @@ internal static class ConsoleUI /// internal static readonly Style GroupHeaderStyle = new(Color.Grey); - private static readonly string[] LogoLines = - [ - @" ____ _ ", - @" | _ \ _____ _____| | __ _ ", - @" | |_) / _ \ \ / / _ \ |/ _` |", - @" | _ < __/\ V / __/ | (_| |", - @" |_| \_\___| \_/ \___|_|\__,_|", - ]; - /// - /// Clears the console and displays the Revela ASCII logo. + /// Clears the console (no banner). /// - public static void ClearAndShowLogo() - { - AnsiConsole.Clear(); - ShowLogo(); - } + /// + /// Version data is no longer shown at startup — use revela info + /// (CLI) or the Info menu group (TUI) for version and host details. + /// + public static void ClearConsole() => AnsiConsole.Clear(); /// - /// Displays the Revela ASCII logo. + /// Displays a compact welcome panel with optional project context. /// - public static void ShowLogo() - { - foreach (var line in LogoLines) - { - AnsiConsole.MarkupLine("[cyan1]" + line + "[/]"); - } - - AnsiConsole.WriteLine(); - } - - /// - /// Displays a welcome panel with version and optional project context. - /// - /// Project name to display (null for no project context) - /// Folder name to display when no project name is set + /// Project name to display (null for no project context). + /// Folder name to display when no project name is set. public static void ShowWelcomePanel(string? projectName = null, string? folderName = null) { - var version = Assembly.GetEntryAssembly()?.GetName().Version?.ToString(3) ?? "1.0.0"; - - var lines = new List - { - $"[bold]Version {version}[/]", - "[dim]Modern static site generator for photographers[/]" - }; + var lines = new List(); if (!string.IsNullOrEmpty(projectName)) { - lines.Add(string.Empty); lines.Add($"[blue]Project:[/] {Markup.Escape(projectName)}"); } else if (!string.IsNullOrEmpty(folderName)) { - lines.Add(string.Empty); lines.Add($"[dim]Directory:[/] {Markup.Escape(folderName)}"); } - lines.Add(string.Empty); + if (lines.Count > 0) + { + lines.Add(string.Empty); + } + lines.Add("[blue]Navigate with[/] [bold]↑↓[/][blue], select with[/] [bold]Enter[/]"); var panel = new Panel(new Markup(string.Join("\n", lines))) - .WithHeader("[cyan1]Welcome[/]") + .WithHeader("[cyan1]Revela[/]") .WithInfoStyle(); AnsiConsole.Write(panel); diff --git a/src/Cli/Hosting/CoreCommandProvider.cs b/src/Cli/Hosting/CoreCommandProvider.cs index 978e128..213d6de 100644 --- a/src/Cli/Hosting/CoreCommandProvider.cs +++ b/src/Cli/Hosting/CoreCommandProvider.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.DependencyInjection; using Spectara.Revela.Commands.Config; +using Spectara.Revela.Commands.Info; using Spectara.Revela.Features.Generate.Commands; using Spectara.Revela.Features.Theme.Commands; using Spectara.Revela.Sdk.Abstractions; @@ -94,6 +95,28 @@ public IEnumerable GetCommands(IServiceProvider services) Group: CommandGroups.Addons, RequiresProject: false); + // ── Info group (TUI rendered inline: Revela / Plugins → / Themes →) ── + var infoCommand = services.GetRequiredService(); + yield return new CommandDescriptor( + infoCommand.Create(), + Order: 10, + Group: CommandGroups.Info, + RequiresProject: false, + InlineInMenu: true, + InlineDefaultActionLabel: "Revela"); + + var infoPluginsCommand = services.GetRequiredService(); + yield return new CommandDescriptor( + infoPluginsCommand.Create(), + ParentCommand: "info", + Order: 20); + + var infoThemesCommand = services.GetRequiredService(); + yield return new CommandDescriptor( + infoThemesCommand.Create(), + ParentCommand: "info", + Order: 30); + // Restore, Plugin, and Packages commands are provided by PackagesCommandProvider // (only available in Cli, not in Cli.Embedded) diff --git a/src/Cli/Hosting/HostBootstrap.cs b/src/Cli/Hosting/HostBootstrap.cs index 14ff305..53023b3 100644 --- a/src/Cli/Hosting/HostBootstrap.cs +++ b/src/Cli/Hosting/HostBootstrap.cs @@ -1,11 +1,16 @@ +using System.CommandLine; +using System.CommandLine.Invocation; +using System.CommandLine.Parsing; using System.Text; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Spectara.Revela.Commands; using Spectara.Revela.Core.Configuration; using Spectara.Revela.Sdk; using Spectara.Revela.Sdk.Abstractions; using Spectara.Revela.Sdk.Configuration; +using Spectara.Revela.Sdk.Hosting; namespace Spectara.Revela.Cli.Hosting; @@ -50,6 +55,11 @@ public static HostApplicationBuilder ConfigureRevela( builder.Services.AddInteractiveMode(); builder.Services.AddPackages(packageSource, builder.Configuration, args); + // Build identity (HostKind, Version, Framework, ...) — single source + // of truth for `--version` and `revela info`. Idempotent registration + // so tests can override. + builder.Services.TryAddSingleton(); + // Register ProjectEnvironment (runtime info about project location) builder.Services.AddOptions() .Configure((env, host) => env.Path = host.ContentRootPath); @@ -67,6 +77,13 @@ public static async Task RunRevelaAsync(this IHost host, string[] args) { var rootCommand = host.UseRevelaCommands(); + // Replace System.CommandLine's default --version action with one that + // prints the human-readable, host-kind-aware identifier. Same string + // is used as the first line of `revela info`. + var buildInfo = host.Services.GetRequiredService(); + var versionOption = rootCommand.Options.OfType().FirstOrDefault(); + versionOption?.Action = new BuildInfoVersionAction(buildInfo); + // Detect interactive mode: no arguments AND interactive terminal var isInteractiveMode = args.Length == 0 && !Console.IsInputRedirected @@ -82,4 +99,19 @@ public static async Task RunRevelaAsync(this IHost host, string[] args) return await rootCommand.Parse(args).InvokeAsync(); } + + /// + /// Synchronous --version action that prints + /// . + /// + private sealed class BuildInfoVersionAction(IBuildInfo buildInfo) : SynchronousCommandLineAction + { + public override bool ClearsParseErrors => true; + + public override int Invoke(ParseResult parseResult) + { + parseResult.InvocationConfiguration.Output.WriteLine(buildInfo.FormatVersionLine()); + return 0; + } + } } diff --git a/src/Cli/Hosting/HostExtensions.cs b/src/Cli/Hosting/HostExtensions.cs index d8777e9..845df13 100644 --- a/src/Cli/Hosting/HostExtensions.cs +++ b/src/Cli/Hosting/HostExtensions.cs @@ -60,6 +60,7 @@ public static RootCommand UseRevelaCommands( groupRegistry.Register(CommandGroups.Content, 20); groupRegistry.Register(CommandGroups.Setup, 30); groupRegistry.Register(CommandGroups.Addons, 40); + groupRegistry.Register(CommandGroups.Info, 90); // Create root command var rootCommand = new RootCommand(description); @@ -95,6 +96,17 @@ void OnCommandRegistered(Command cmd, CommandDescriptor desc) pipelineOrderProvider.Register(desc.ParentCommand, cmd.Name, desc.Order); } } + + if (desc.InlineInMenu) + { + if (string.IsNullOrEmpty(desc.InlineDefaultActionLabel)) + { + throw new InvalidOperationException( + $"Command '{cmd.Name}' has InlineInMenu=true but no InlineDefaultActionLabel. " + + "Provide a label for the virtual default-action menu entry."); + } + orderRegistry.RegisterInlinedParent(cmd, desc.InlineDefaultActionLabel); + } } // Core commands (via CoreCommandProvider — uses same registration as plugins diff --git a/src/Cli/Hosting/InteractiveMenuService.cs b/src/Cli/Hosting/InteractiveMenuService.cs index dc030c3..e3db8bc 100644 --- a/src/Cli/Hosting/InteractiveMenuService.cs +++ b/src/Cli/Hosting/InteractiveMenuService.cs @@ -72,7 +72,7 @@ public async Task RunAsync(CancellationToken cancellationToken = default) private async Task HandleFirstRunAsync(CancellationToken cancellationToken) { - ConsoleUI.ClearAndShowLogo(); + ConsoleUI.ClearConsole(); ConsoleUI.ShowFirstRunPanel(); var wizard = setupWizards.FirstOrDefault(); @@ -111,7 +111,7 @@ private async Task HandleFirstRunAsync(CancellationToken cancellationToken) private async Task HandleNoProjectAsync(CancellationToken cancellationToken) { - ConsoleUI.ClearAndShowLogo(); + ConsoleUI.ClearConsole(); var folderName = projectEnvironment.Value.FolderName; @@ -175,7 +175,7 @@ private void ShowWelcomeBanner() bannerShown = true; - ConsoleUI.ClearAndShowLogo(); + ConsoleUI.ClearConsole(); // Show project name if initialized, otherwise show folder name var projectName = projectConfig.CurrentValue.Name; @@ -208,14 +208,15 @@ private async Task ShowMainMenuAsync(CancellationToken cancellationT var selection = AnsiConsole.Prompt(prompt); + // Top-level dispatch: Exit/Back/Wizard handled here; Navigate/Execute + // delegate to HandleMenuActionAsync so CommandPathOverride from inlined + // entries (e.g. "Plugins" → ["info","plugins"]) is honored. return selection.Action switch { MenuAction.Exit => new MenuResult(true, 0), MenuAction.Back => new MenuResult(false, 0), - MenuAction.Navigate => await NavigateToCommandAsync(selection.Command!, [selection.Command!.Name], cancellationToken), - MenuAction.Execute => await ExecuteCommandAsync(selection.Command!, [selection.Command!.Name], cancellationToken), MenuAction.RunSetupWizard => await RunSetupWizardAsync(cancellationToken), - _ => new MenuResult(false, 0), + MenuAction.Navigate or MenuAction.Execute or _ => await HandleMenuActionAsync(selection, [], cancellationToken), }; } @@ -295,7 +296,9 @@ private async Task HandleMenuActionAsync( return await RunSetupWizardAsync(cancellationToken); } - var extendedPath = new List(commandPath) { selection.Command!.Name }; + var extendedPath = selection.CommandPathOverride is not null + ? [.. selection.CommandPathOverride] + : new List(commandPath) { selection.Command!.Name }; return selection.Action switch { @@ -388,7 +391,7 @@ private SelectionPrompt BuildGroupedSelectionPrompt( // Calculate column width from longest command name (+ 2 for " →" arrow) var allCommands = grouped.SelectMany(g => g.Commands).ToList(); var nameWidth = allCommands.Count > 0 - ? allCommands.Max(c => c.Name.Length + (c.Subcommands.Any(s => !s.Hidden) ? 2 : 0)) + 2 + ? allCommands.Max(c => MaxRenderedNameLength(c)) + 2 : 0; if (hasGroups) @@ -406,7 +409,11 @@ private SelectionPrompt BuildGroupedSelectionPrompt( { // Create group header as MenuChoice (will be non-selectable due to Leaf mode) var groupChoice = new MenuChoice(groupName, Action: MenuAction.Navigate); - var commandChoices = groupCommands.Select(c => MenuChoice.FromCommand(c, orderRegistry.IsPipelineStep(c), nameWidth)).ToList(); + var commandChoices = new List(); + foreach (var cmd in groupCommands) + { + AddGroupCommandChoices(commandChoices, cmd, nameWidth); + } // Add Wizard to the Addons group (as last item) if (includeSetupWizard && groupName == CommandGroups.Addons) @@ -421,7 +428,7 @@ private SelectionPrompt BuildGroupedSelectionPrompt( // Ungrouped commands - add directly foreach (var cmd in groupCommands) { - prompt.AddChoice(MenuChoice.FromCommand(cmd, orderRegistry.IsPipelineStep(cmd), nameWidth)); + AddUngroupedCommandChoices(prompt, cmd, nameWidth); } } } @@ -444,6 +451,91 @@ private SelectionPrompt BuildGroupedSelectionPrompt( return prompt; } + /// + /// Adds menu choices for a command appearing in a grouped section. + /// Inlined parents are expanded into a virtual default-action entry plus + /// each visible subcommand (with absolute path overrides). + /// + private void AddGroupCommandChoices(List choices, Command cmd, int nameWidth) + { + if (TryAddInlined(cmd, nameWidth, choices.Add)) + { + return; + } + choices.Add(MenuChoice.FromCommand(cmd, orderRegistry.IsPipelineStep(cmd), nameWidth)); + } + + /// + /// Adds menu choices for an ungrouped command (rare). + /// Inlined parents behave the same way as in grouped sections. + /// + private void AddUngroupedCommandChoices(SelectionPrompt prompt, Command cmd, int nameWidth) + { + if (TryAddInlined(cmd, nameWidth, choice => prompt.AddChoice(choice))) + { + return; + } + prompt.AddChoice(MenuChoice.FromCommand(cmd, orderRegistry.IsPipelineStep(cmd), nameWidth)); + } + + private bool TryAddInlined(Command cmd, int nameWidth, Action add) + { + var label = orderRegistry.GetInlineDefaultActionLabel(cmd); + if (label is null) + { + return false; + } + + // Virtual default-action entry: label runs the parent without subcommand + add(MenuChoice.CreateInlinedDefaultAction(cmd, label, nameWidth)); + + // Each visible subcommand that itself has at least one visible sub-subcommand + // (extension entries registered via ParentCommand: " "). + // Subcommands without any extension are skipped — the parent's default + // action already covers their content (e.g. count/list summary). + // Absolute path override (parent + sub) ensures correct CLI dispatch. + foreach (var sub in cmd.Subcommands + .Where(IsInlinableSub) + .OrderBy(orderRegistry.GetOrder) + .ThenBy(s => s.Name, StringComparer.OrdinalIgnoreCase)) + { + add(MenuChoice.FromCommand( + sub, + orderRegistry.IsPipelineStep(sub), + nameWidth, + commandPathOverride: [cmd.Name, sub.Name])); + } + return true; + } + + private static bool IsInlinableSub(Command sub) => + !sub.Hidden && sub.Subcommands.Any(s => !s.Hidden); + + /// + /// Computes the rendered name length used for column alignment, accounting + /// for the inline expansion of parents into a default-action label plus + /// subcommand entries (each with the " →" arrow when navigable). + /// + private int MaxRenderedNameLength(Command cmd) + { + var label = orderRegistry.GetInlineDefaultActionLabel(cmd); + if (label is null) + { + return cmd.Name.Length + (cmd.Subcommands.Any(s => !s.Hidden) ? 2 : 0); + } + + var max = label.Length; + foreach (var sub in cmd.Subcommands.Where(IsInlinableSub)) + { + var len = sub.Name.Length + 2; // always navigable when shown + if (len > max) + { + max = len; + } + } + return max; + } + [LoggerMessage(Level = LogLevel.Error, Message = "RootCommand not set")] private static partial void LogRootCommandNotSet(ILogger logger); diff --git a/src/Cli/Hosting/MenuChoice.cs b/src/Cli/Hosting/MenuChoice.cs index e5d1089..33354a4 100644 --- a/src/Cli/Hosting/MenuChoice.cs +++ b/src/Cli/Hosting/MenuChoice.cs @@ -8,10 +8,16 @@ namespace Spectara.Revela.Cli.Hosting; /// The text shown in the menu. /// The associated command, if any. /// The action type for this choice. +/// +/// When set, replaces the default extended path (current path + selected +/// command name) with this absolute path. Used by inlined parent rendering +/// where a top-level menu entry actually points at a nested subcommand. +/// internal sealed record MenuChoice( string DisplayName, Command? Command = null, - MenuAction Action = MenuAction.Navigate) + MenuAction Action = MenuAction.Navigate, + IReadOnlyList? CommandPathOverride = null) { /// /// Creates a "Back" menu choice for navigation. @@ -33,14 +39,49 @@ public static MenuChoice CreateWizard(int nameColumnWidth = 0) return new($"{paddedName} [dim]Install themes and plugins[/]", Action: MenuAction.RunSetupWizard); } + /// + /// Creates the virtual default-action menu entry for an inlined parent. + /// + /// The inlined parent command. + /// Display label (e.g. "Revela"). + /// Width to pad the name column to for alignment. + /// + /// A choice that, when selected, invokes the parent command with no + /// subcommand arguments — triggering its default action. + /// + public static MenuChoice CreateInlinedDefaultAction(Command parent, string label, int nameColumnWidth = 0) + { + var paddedName = nameColumnWidth > 0 ? label.PadRight(nameColumnWidth) : label; + var description = string.IsNullOrWhiteSpace(parent.Description) + ? string.Empty + : $" [dim]{parent.Description}[/]"; + + // Path override = just the parent name, so CommandExecutor invokes + // the parent without any subcommand → default action runs. + return new MenuChoice( + DisplayName: $"{paddedName}{description}", + Command: parent, + Action: MenuAction.Execute, + CommandPathOverride: [parent.Name]); + } + /// /// Creates a menu choice from a command. /// /// The command to create a choice for. /// Whether this command is a pipeline step (shown with marker). /// Width to pad the name column to for alignment. + /// + /// Optional absolute path to use when this choice is selected. Used for + /// inlined subcommands where the menu position differs from the CLI tree + /// position. + /// /// A menu choice with the command's name and description. - public static MenuChoice FromCommand(Command cmd, bool isPipelineStep = false, int nameColumnWidth = 0) + public static MenuChoice FromCommand( + Command cmd, + bool isPipelineStep = false, + int nameColumnWidth = 0, + IReadOnlyList? commandPathOverride = null) { var hasVisibleSubcommands = cmd.Subcommands.Any(c => !c.Hidden); var arrow = hasVisibleSubcommands ? " →" : ""; @@ -56,7 +97,7 @@ public static MenuChoice FromCommand(Command cmd, bool isPipelineStep = false, i ? MenuAction.Navigate : MenuAction.Execute; - return new MenuChoice($"{paddedName}{marker}{description}", cmd, action); + return new MenuChoice($"{paddedName}{marker}{description}", cmd, action, commandPathOverride); } /// diff --git a/src/Commands/Info/InfoCommand.cs b/src/Commands/Info/InfoCommand.cs new file mode 100644 index 0000000..a7b05c0 --- /dev/null +++ b/src/Commands/Info/InfoCommand.cs @@ -0,0 +1,82 @@ +using System.CommandLine; +using System.Globalization; + +using Microsoft.Extensions.Options; + +using Spectara.Revela.Sdk; +using Spectara.Revela.Sdk.Abstractions; +using Spectara.Revela.Sdk.Configuration; +using Spectara.Revela.Sdk.Hosting; + +using Spectre.Console; + +namespace Spectara.Revela.Commands.Info; + +/// +/// Parent command for Revela diagnostic information. +/// +/// +/// +/// Default action prints a compact Revela summary (version, framework, host +/// kind, plugin/theme counts, active theme). Subcommands plugins and +/// themes show detail tables. Plugins may register additional detail +/// commands via ParentCommand: "info plugins". +/// +/// +/// The first line of output is +/// — the same string printed by revela --version. +/// +/// +internal sealed class InfoCommand( + IBuildInfo buildInfo, + IPackageContext packageContext, + IOptionsMonitor themeConfig) +{ + /// Creates the command definition. + public Command Create() + { + var command = new Command("info", "Show Revela version and host info"); + command.SetAction(_ => Execute()); + return command; + } + + private int Execute() + { + AnsiConsole.MarkupLine($"[bold]{Markup.Escape(buildInfo.FormatVersionLine())}[/]"); + + if (buildInfo.Kind == HostKind.Embedded) + { + AnsiConsole.MarkupLine( + "[dim]Plugin management: not available in embedded build (use the standalone CLI)[/]"); + } + + AnsiConsole.WriteLine(); + + var pluginCount = packageContext.Plugins.Count; + var themeCount = packageContext.Themes.Count; + var activeThemeName = themeConfig.CurrentValue.Name; + + var summary = new List + { + $"[blue]Plugins:[/] {pluginCount.ToString(CultureInfo.InvariantCulture)}", + $"[blue]Themes:[/] {themeCount.ToString(CultureInfo.InvariantCulture)}", + string.IsNullOrEmpty(activeThemeName) + ? "[blue]Active theme:[/] [dim](no project)[/]" + : $"[blue]Active theme:[/] {Markup.Escape(activeThemeName)}", + string.Empty, + $"[dim]Build:[/] {Markup.Escape(buildInfo.Configuration)} ({Markup.Escape(buildInfo.RuntimeIdentifier)})", + $"[dim]Build id:[/] {Markup.Escape(buildInfo.InformationalVersion)}", + }; + + var panel = new Panel(new Markup(string.Join("\n", summary))) + .WithHeader("[bold]Revela[/]") + .WithInfoStyle(); + panel.Padding = new Padding(1, 0, 1, 0); + AnsiConsole.Write(panel); + + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine("[dim]For details: [white]revela info plugins[/] · [white]revela info themes[/][/]"); + + return 0; + } +} diff --git a/src/Commands/Info/InfoPluginsCommand.cs b/src/Commands/Info/InfoPluginsCommand.cs new file mode 100644 index 0000000..2e5628b --- /dev/null +++ b/src/Commands/Info/InfoPluginsCommand.cs @@ -0,0 +1,82 @@ +using System.CommandLine; +using System.Globalization; + +using Spectara.Revela.Sdk; +using Spectara.Revela.Sdk.Abstractions; + +using Spectre.Console; + +namespace Spectara.Revela.Commands.Info; + +/// +/// Lists installed plugins with version and source. +/// +/// +/// Plugin authors may add per-plugin detail subcommands via +/// ParentCommand: "info plugins"; those appear under revela info +/// plugins <name> and as sub-entries in the TUI menu. They should +/// be read-only diagnostics (no prompts), kept compact for bug-report +/// copy-paste. +/// +internal sealed class InfoPluginsCommand(IPackageContext packageContext) +{ + /// Creates the command definition. + public Command Create() + { + var command = new Command("plugins", "List installed plugins"); + command.SetAction(_ => Execute()); + return command; + } + + private int Execute() + { + var plugins = packageContext.Plugins + .OrderBy(p => p.Plugin.Metadata.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (plugins.Count == 0) + { + var emptyPanel = new Panel(new Markup("[dim]No plugins loaded.[/]")) + .WithHeader("[bold]Plugins[/] [dim](0)[/]") + .WithInfoStyle(); + emptyPanel.Padding = new Padding(1, 0, 1, 0); + AnsiConsole.Write(emptyPanel); + return 0; + } + + var lines = new List(); + foreach (var info in plugins) + { + var meta = info.Plugin.Metadata; + var sourceMarkup = FormatSource(info.Source); + lines.Add($"[bold green]{Markup.Escape(meta.Name)}[/] [dim]v{Markup.Escape(meta.Version)}[/] {sourceMarkup}"); + if (!string.IsNullOrWhiteSpace(meta.Description)) + { + lines.Add($" [dim]{Markup.Escape(meta.Description)}[/]"); + } + lines.Add($" [dim]{Markup.Escape(meta.Id)}[/]"); + lines.Add(string.Empty); + } + + // Drop trailing blank + if (lines.Count > 0 && lines[^1].Length == 0) + { + lines.RemoveAt(lines.Count - 1); + } + + var panel = new Panel(new Markup(string.Join("\n", lines))) + .WithHeader($"[bold]Plugins[/] [dim]({plugins.Count.ToString(CultureInfo.InvariantCulture)})[/]") + .WithInfoStyle(); + panel.Padding = new Padding(1, 0, 1, 0); + AnsiConsole.Write(panel); + + return 0; + } + + private static string FormatSource(PackageSource source) => source switch + { + PackageSource.Bundled => "[grey][[bundled]][/]", + PackageSource.Local => "[cyan][[local]][/]", + _ => $"[grey][[{source}]][/]", + }; +} diff --git a/src/Commands/Info/InfoThemesCommand.cs b/src/Commands/Info/InfoThemesCommand.cs new file mode 100644 index 0000000..c2e4ba9 --- /dev/null +++ b/src/Commands/Info/InfoThemesCommand.cs @@ -0,0 +1,95 @@ +using System.CommandLine; +using System.Globalization; + +using Microsoft.Extensions.Options; + +using Spectara.Revela.Sdk; +using Spectara.Revela.Sdk.Abstractions; +using Spectara.Revela.Sdk.Configuration; + +using Spectre.Console; + +namespace Spectara.Revela.Commands.Info; + +/// +/// Lists installed themes with version, source, and the active marker. +/// +/// +/// The active theme is read from and only +/// highlighted when a project is loaded. Theme authors may add per-theme +/// detail subcommands via ParentCommand: "info themes". +/// +internal sealed class InfoThemesCommand( + IPackageContext packageContext, + IOptionsMonitor themeConfig) +{ + /// Creates the command definition. + public Command Create() + { + var command = new Command("themes", "List installed themes"); + command.SetAction(_ => Execute()); + return command; + } + + private int Execute() + { + var themes = packageContext.Themes + .OrderBy(t => t.Theme.Metadata.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + var activeName = themeConfig.CurrentValue.Name; + + if (themes.Count == 0) + { + var emptyPanel = new Panel(new Markup("[dim]No themes loaded.[/]")) + .WithHeader("[bold]Themes[/] [dim](0)[/]") + .WithInfoStyle(); + emptyPanel.Padding = new Padding(1, 0, 1, 0); + AnsiConsole.Write(emptyPanel); + return 0; + } + + var lines = new List(); + foreach (var info in themes) + { + var meta = info.Theme.Metadata; + var sourceMarkup = FormatSource(info.Source); + var isActive = !string.IsNullOrEmpty(activeName) + && string.Equals(meta.Name, activeName, StringComparison.OrdinalIgnoreCase); + var marker = isActive ? "[yellow]★[/] " : " "; + + lines.Add($"{marker}[bold green]{Markup.Escape(meta.Name)}[/] [dim]v{Markup.Escape(meta.Version)}[/] {sourceMarkup}"); + if (!string.IsNullOrWhiteSpace(meta.Description)) + { + lines.Add($" [dim]{Markup.Escape(meta.Description)}[/]"); + } + lines.Add($" [dim]{Markup.Escape(meta.Id)}[/]"); + lines.Add(string.Empty); + } + + if (lines.Count > 0 && lines[^1].Length == 0) + { + lines.RemoveAt(lines.Count - 1); + } + + var panel = new Panel(new Markup(string.Join("\n", lines))) + .WithHeader($"[bold]Themes[/] [dim]({themes.Count.ToString(CultureInfo.InvariantCulture)})[/]") + .WithInfoStyle(); + panel.Padding = new Padding(1, 0, 1, 0); + AnsiConsole.Write(panel); + + if (string.IsNullOrEmpty(activeName)) + { + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine("[dim](no active theme — open a Revela project to see the active marker)[/]"); + } + + return 0; + } + + private static string FormatSource(PackageSource source) => source switch + { + PackageSource.Bundled => "[grey][[bundled]][/]", + PackageSource.Local => "[cyan][[local]][/]", + _ => $"[grey][[{source}]][/]", + }; +} diff --git a/src/Commands/ServiceCollectionExtensions.cs b/src/Commands/ServiceCollectionExtensions.cs index 6bf5e4d..3fc3204 100644 --- a/src/Commands/ServiceCollectionExtensions.cs +++ b/src/Commands/ServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Spectara.Revela.Commands.Config; +using Spectara.Revela.Commands.Info; using Spectara.Revela.Core.Services; using Spectara.Revela.Features.Generate; using Spectara.Revela.Features.Theme; @@ -19,7 +20,7 @@ internal static class ServiceCollectionExtensions /// Adds all Revela command services to the DI container. /// /// - /// Registers host-owned commands (Config) and core features (Generate, Theme). + /// Registers host-owned commands (Config, Info) and core features (Generate, Theme). /// Package management commands (Packages, Plugins, Restore) are registered separately /// via AddPackageManagement() in the Packages feature — only loaded by Cli, /// not by Cli.Embedded. @@ -37,6 +38,11 @@ public static IServiceCollection AddRevelaCommands(this IServiceCollection servi // Host-owned commands services.AddConfigFeature(); + // Info commands (always available, in both Cli and Cli.Embedded) + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + // Core features — always available, not plugin-loaded services.AddGenerateFeature(); services.AddThemeFeature(); diff --git a/src/Sdk/Abstractions/CommandDescriptor.cs b/src/Sdk/Abstractions/CommandDescriptor.cs index fee118d..02bfd77 100644 --- a/src/Sdk/Abstractions/CommandDescriptor.cs +++ b/src/Sdk/Abstractions/CommandDescriptor.cs @@ -39,6 +39,20 @@ namespace Spectara.Revela.Sdk.Abstractions; /// and by the "all" command to discover steps to run in Order sequence. /// Default is false. /// +/// +/// When true, the interactive menu does NOT render this command as a single +/// entry. Instead it renders the command's default action as a virtual entry +/// (labeled by ) followed by each of +/// the command's visible subcommands directly under the group label. The CLI +/// surface is unchanged. Only meaningful when the command has subcommands and +/// is registered directly under root (with a ). +/// Default is false. +/// +/// +/// Display label for the virtual default-action entry generated when +/// is true. Required when +/// is true; ignored otherwise. +/// /// /// /// // Register under "init" parent: revela init onedrive @@ -57,6 +71,11 @@ namespace Spectara.Revela.Sdk.Abstractions; /// // Sequential step: part of a pipeline that supports "all" /// new CommandDescriptor(scanCmd, "generate", Order: 10, /// IsSequentialStep: true) +/// +/// // Inline a parent command flat under its group (e.g. info → Revela / Plugins → / Themes →) +/// new CommandDescriptor(infoCmd, Order: 10, Group: "Info", +/// RequiresProject: false, +/// InlineInMenu: true, InlineDefaultActionLabel: "Revela") /// /// public sealed record CommandDescriptor( @@ -66,4 +85,6 @@ public sealed record CommandDescriptor( string? Group = null, bool RequiresProject = true, bool HideWhenProjectExists = false, - bool IsSequentialStep = false); + bool IsSequentialStep = false, + bool InlineInMenu = false, + string? InlineDefaultActionLabel = null); diff --git a/src/Sdk/Hosting/IBuildInfo.cs b/src/Sdk/Hosting/IBuildInfo.cs new file mode 100644 index 0000000..65a932c --- /dev/null +++ b/src/Sdk/Hosting/IBuildInfo.cs @@ -0,0 +1,75 @@ +namespace Spectara.Revela.Sdk.Hosting; + +/// +/// Immutable build-time identity of the running Revela host. +/// +/// +/// +/// Single source of truth for version data exposed via revela --version, +/// revela info, and any plugin that needs to branch on host kind. +/// +/// +/// Note: describes the build-time +/// identity of the host (Standalone vs. Embedded), determined at compile time +/// via the Revela.HostKind assembly metadata attribute. +/// It is not user-overridable. +/// +/// +/// This is conceptually distinct from +/// Microsoft.Extensions.Hosting.IHostEnvironment, which describes the +/// runtime deployment environment (Development/Staging/Production) +/// and is overridable via DOTNET_ENVIRONMENT. The two are +/// complementary: IHostEnvironment answers "where am I running?", +/// IBuildInfo answers "what build am I?". +/// +/// +public interface IBuildInfo +{ + /// Build variant of the running host. + HostKind Kind { get; } + + /// Clean semantic version, e.g. "1.0.0". + string Version { get; } + + /// + /// Full informational version including build metadata, + /// e.g. "1.0.0+abc1234". Use this in bug reports. + /// + string InformationalVersion { get; } + + /// .NET runtime description, e.g. ".NET 10.0.4". + string Framework { get; } + + /// Build configuration: "Debug" or "Release". + string Configuration { get; } + + /// Runtime identifier, e.g. "linux-x64". + string RuntimeIdentifier { get; } + + /// + /// Single-line human-readable summary used by both --version + /// and the first line of revela info. + /// + /// + /// revela 1.0.0 (.NET 10.0.4) — embedded build + /// + string FormatVersionLine(); +} + +/// +/// Build variant of the running Revela host. +/// +public enum HostKind +{ + /// + /// Standard standalone CLI build with dynamic plugin loading + /// (the revela dotnet tool). + /// + Standalone, + + /// + /// Self-contained build with all plugins and themes statically linked. + /// No plugin management commands are available. + /// + Embedded, +} diff --git a/tests/Cli/AssemblyInfo.cs b/tests/Cli/AssemblyInfo.cs new file mode 100644 index 0000000..a9b5e29 --- /dev/null +++ b/tests/Cli/AssemblyInfo.cs @@ -0,0 +1,2 @@ +// Enable parallel test execution at assembly level +[assembly: Parallelize(Workers = 0, Scope = ExecutionScope.MethodLevel)] diff --git a/tests/Cli/Cli.csproj b/tests/Cli/Cli.csproj new file mode 100644 index 0000000..6277775 --- /dev/null +++ b/tests/Cli/Cli.csproj @@ -0,0 +1,33 @@ + + + + net10.0 + Exe + enable + enable + false + + + true + true + + + $(NoWarn);CA1515 + + + + + + + + + + + + + + + + + + diff --git a/tests/Cli/Hosting/BuildInfoTests.cs b/tests/Cli/Hosting/BuildInfoTests.cs new file mode 100644 index 0000000..6105388 --- /dev/null +++ b/tests/Cli/Hosting/BuildInfoTests.cs @@ -0,0 +1,103 @@ +using System.Reflection; + +using Spectara.Revela.Cli.Hosting; +using Spectara.Revela.Sdk.Hosting; + +namespace Spectara.Revela.Tests.Cli.Hosting; + +[TestClass] +[TestCategory("Unit")] +public sealed class BuildInfoTests +{ + [TestMethod] + public void FormatVersionLine_Standalone_OmitsHostSuffix() + { + var info = new BuildInfo( + HostKind.Standalone, + version: "1.2.3", + informationalVersion: "1.2.3+abc1234", + framework: ".NET 10.0.4", + configuration: "Release", + runtimeIdentifier: "linux-x64"); + + var line = info.FormatVersionLine(); + + Assert.AreEqual("revela 1.2.3 (.NET 10.0.4)", line); + } + + [TestMethod] + public void FormatVersionLine_Embedded_AppendsEmbeddedSuffix() + { + var info = new BuildInfo( + HostKind.Embedded, + version: "1.2.3", + informationalVersion: "1.2.3", + framework: ".NET 10.0.4", + configuration: "Release", + runtimeIdentifier: "linux-x64"); + + var line = info.FormatVersionLine(); + + Assert.AreEqual("revela 1.2.3 (.NET 10.0.4) \u2014 embedded build", line); + } + + [TestMethod] + [DataRow("1.0.0", "1.0.0")] + [DataRow("1.0.0+abc1234", "1.0.0")] + [DataRow("1.0.0-rc.1+abc1234", "1.0.0-rc.1")] + [DataRow("2.5.0+", "2.5.0")] + public void StripBuildMetadata_RemovesEverythingFromFirstPlus(string input, string expected) + { + var actual = BuildInfo.StripBuildMetadataForTesting(input); + + Assert.AreEqual(expected, actual); + } + + [TestMethod] + public void DetectHostKind_AssemblyWithoutMetadata_ReturnsStandalone() + { + // The test runner assembly itself has no Revela.HostKind metadata. + var asm = Assembly.GetExecutingAssembly(); + + var kind = BuildInfo.DetectHostKindForTesting(asm); + + Assert.AreEqual(HostKind.Standalone, kind); + } + + [TestMethod] + public void DetectHostKind_CliEmbeddedAssembly_ReturnsEmbedded() + { + // Locate Cli.Embedded's revela.dll relative to this test assembly. + // Layout: artifacts/bin/Tests.Cli/Debug/net10.0/ → ../../../Cli.Embedded/Debug/net10.0/revela.dll + var testAsmDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!; + var embeddedDll = Path.GetFullPath(Path.Combine( + testAsmDir, "..", "..", "..", "Cli.Embedded", "Debug", "net10.0", "revela.dll")); + + if (!File.Exists(embeddedDll)) + { + Assert.Inconclusive($"Cli.Embedded build artifact not found at '{embeddedDll}'. Run a full solution build first."); + } + + var asm = Assembly.LoadFile(embeddedDll); + + var kind = BuildInfo.DetectHostKindForTesting(asm); + + Assert.AreEqual(HostKind.Embedded, kind); + } + + [TestMethod] + public void Constructor_FromTestRunnerAssembly_FillsAllProperties() + { + var info = new BuildInfo(Assembly.GetExecutingAssembly()); + + Assert.AreEqual(HostKind.Standalone, info.Kind); + Assert.IsFalse(string.IsNullOrEmpty(info.Version)); + Assert.IsFalse(string.IsNullOrEmpty(info.Framework)); + Assert.IsFalse(string.IsNullOrEmpty(info.RuntimeIdentifier)); + // Configuration is build-time; either Debug or Release depending on test run config. + Assert.IsTrue( + info.Configuration is "Debug" or "Release", + $"Expected Debug or Release, got '{info.Configuration}'."); + Assert.IsFalse(info.Version.Contains('+', StringComparison.Ordinal)); + } +} diff --git a/tests/Commands/Info/InfoCommandTests.cs b/tests/Commands/Info/InfoCommandTests.cs new file mode 100644 index 0000000..9f39045 --- /dev/null +++ b/tests/Commands/Info/InfoCommandTests.cs @@ -0,0 +1,62 @@ +using Microsoft.Extensions.Options; + +using NSubstitute; + +using Spectara.Revela.Commands.Info; +using Spectara.Revela.Sdk.Abstractions; +using Spectara.Revela.Sdk.Configuration; +using Spectara.Revela.Sdk.Hosting; + +namespace Spectara.Revela.Tests.Commands.Info; + +[TestClass] +[TestCategory("Unit")] +public sealed class InfoCommandTests +{ + [TestMethod] + public void Create_ReturnsInfoCommandWithDescription() + { + var command = CreateCommand().Create(); + + Assert.AreEqual("info", command.Name); + Assert.IsFalse(string.IsNullOrEmpty(command.Description)); + } + + [TestMethod] + public void Create_HasNoSubcommandsByDefault() + { + // Subcommands are attached by HostExtensions via ParentCommand: "info" + // routing, not by InfoCommand itself. + var command = CreateCommand().Create(); + + Assert.IsEmpty(command.Subcommands); + } + + [TestMethod] + public void Create_HasNoOptions() + { + // info default action takes no arguments. + var command = CreateCommand().Create(); + + Assert.IsEmpty(command.Arguments); + } + + private static InfoCommand CreateCommand() + { + var buildInfo = Substitute.For(); + buildInfo.Kind.Returns(HostKind.Standalone); + buildInfo.FormatVersionLine().Returns("revela 1.0.0 (.NET 10.0.4)"); + buildInfo.InformationalVersion.Returns("1.0.0"); + buildInfo.Configuration.Returns("Debug"); + buildInfo.RuntimeIdentifier.Returns("linux-x64"); + + var packageContext = Substitute.For(); + packageContext.Plugins.Returns([]); + packageContext.Themes.Returns([]); + + var themeConfig = Substitute.For>(); + themeConfig.CurrentValue.Returns(new ThemeConfig()); + + return new InfoCommand(buildInfo, packageContext, themeConfig); + } +} diff --git a/tests/Commands/Info/InfoPluginsCommandTests.cs b/tests/Commands/Info/InfoPluginsCommandTests.cs new file mode 100644 index 0000000..9a13403 --- /dev/null +++ b/tests/Commands/Info/InfoPluginsCommandTests.cs @@ -0,0 +1,46 @@ +using NSubstitute; + +using Spectara.Revela.Commands.Info; +using Spectara.Revela.Sdk.Abstractions; + +namespace Spectara.Revela.Tests.Commands.Info; + +[TestClass] +[TestCategory("Unit")] +public sealed class InfoPluginsCommandTests +{ + [TestMethod] + public void Create_ReturnsPluginsCommandWithDescription() + { + var command = CreateCommand([]).Create(); + + Assert.AreEqual("plugins", command.Name); + Assert.IsFalse(string.IsNullOrEmpty(command.Description)); + } + + [TestMethod] + public void Create_HasNoOptionsOrArguments() + { + // plugins listing takes no arguments — read-only diagnostic. + var command = CreateCommand([]).Create(); + + Assert.IsEmpty(command.Arguments); + } + + [TestMethod] + public void Create_HasNoSubcommandsByDefault() + { + // Per-plugin detail subcommands are added by plugins themselves via + // ParentCommand: "info plugins" — not by this command. + var command = CreateCommand([]).Create(); + + Assert.IsEmpty(command.Subcommands); + } + + private static InfoPluginsCommand CreateCommand(IReadOnlyList plugins) + { + var packageContext = Substitute.For(); + packageContext.Plugins.Returns(plugins); + return new InfoPluginsCommand(packageContext); + } +} diff --git a/tests/Commands/Info/InfoThemesCommandTests.cs b/tests/Commands/Info/InfoThemesCommandTests.cs new file mode 100644 index 0000000..63329f1 --- /dev/null +++ b/tests/Commands/Info/InfoThemesCommandTests.cs @@ -0,0 +1,54 @@ +using Microsoft.Extensions.Options; + +using NSubstitute; + +using Spectara.Revela.Commands.Info; +using Spectara.Revela.Sdk.Abstractions; +using Spectara.Revela.Sdk.Configuration; + +namespace Spectara.Revela.Tests.Commands.Info; + +[TestClass] +[TestCategory("Unit")] +public sealed class InfoThemesCommandTests +{ + [TestMethod] + public void Create_ReturnsThemesCommandWithDescription() + { + var command = CreateCommand([], activeThemeName: "").Create(); + + Assert.AreEqual("themes", command.Name); + Assert.IsFalse(string.IsNullOrEmpty(command.Description)); + } + + [TestMethod] + public void Create_HasNoOptionsOrArguments() + { + var command = CreateCommand([], activeThemeName: "").Create(); + + Assert.IsEmpty(command.Arguments); + } + + [TestMethod] + public void Create_HasNoSubcommandsByDefault() + { + // Per-theme detail subcommands are added by themes themselves via + // ParentCommand: "info themes" — not by this command. + var command = CreateCommand([], activeThemeName: "").Create(); + + Assert.IsEmpty(command.Subcommands); + } + + private static InfoThemesCommand CreateCommand( + IReadOnlyList themes, + string activeThemeName) + { + var packageContext = Substitute.For(); + packageContext.Themes.Returns(themes); + + var themeConfig = Substitute.For>(); + themeConfig.CurrentValue.Returns(new ThemeConfig { Name = activeThemeName }); + + return new InfoThemesCommand(packageContext, themeConfig); + } +}