diff --git a/Clet.slnx b/Clet.slnx index 7efeb14..0edf81d 100644 --- a/Clet.slnx +++ b/Clet.slnx @@ -1,8 +1,10 @@ + + diff --git a/Directory.Build.props b/Directory.Build.props index 7191562..ac6c604 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -13,9 +13,9 @@ Copyright (c) gui-cs and contributors - 2.2.2 + 2.4.1-develop.11 - 2.2.5 + 2.4.1 diff --git a/specs/stage-a-progress.md b/specs/stage-a-progress.md new file mode 100644 index 0000000..b3a875b --- /dev/null +++ b/specs/stage-a-progress.md @@ -0,0 +1,159 @@ +# Stage A Progress: Terminal.Gui.Cli Library Extraction + +## Status: COMPLETE ✅ — All Phases Done + +## Overview + +Implementing Stage A of the Terminal.Gui.Cli library spec: extracting the hosting infrastructure from clet into a reusable class library at `src/Terminal.Gui.Cli/`, then migrating clet to consume it. + +## Approach + +**Test-first**: Write tests for the library's public API surface against the spec before implementing. Then implement to make tests pass. + +## Phases + +### Phase 1: Project scaffolding ✅ +- [x] Create `src/Terminal.Gui.Cli/Terminal.Gui.Cli.csproj` +- [x] Create `tests/Terminal.Gui.Cli.Tests/Terminal.Gui.Cli.Tests.csproj` +- [x] Add both to `Clet.slnx` +- [x] Add `AssemblyInfo.cs` with InternalsVisibleTo + +### Phase 2: Core Abstractions (test-first) ✅ +- [x] `CommandKind` enum +- [x] `CommandStatus` enum +- [x] `CommandOptionDescriptor` record +- [x] `CommandResult` (non-generic + generic) +- [x] `ICliCommand` interface +- [x] `ICliCommand` generic interface +- [x] `IViewerCommand` interface +- [x] `ICommandRegistry` interface +- [x] `CommandRunOptions` class +- [x] `GlobalOptionDescriptor` record + +### Phase 3: Registry & Exit Codes (test-first) ✅ +- [x] `CommandRegistry` implementation +- [x] `ExitCodes` static class + +### Phase 4: JSON Envelope & Output (test-first) ✅ +- [x] `JsonEnvelope` class +- [x] `CliJsonContext` source-generated +- [x] `TypeNames` static class +- [x] `ResultWriter` static class + +### Phase 5: Arg Parser (test-first — rewrite from scratch) ✅ +- [x] Data-driven parser consuming `GlobalOptionDescriptor` registrations +- [x] Framework-owned flags (--json, --initial, --timeout, etc.) +- [x] Consumer-registered global options +- [x] Per-command option validation +- [x] `--option=value` syntax support +- [x] `--` separator (end of options) +- [x] Unknown option rejection (exit 2) + +### Phase 6: Host & Dispatcher ✅ +- [x] `CliHostOptions` class +- [x] `CliHost` class (with TUI dispatch, cancellation, ConfigurationManager) +- [x] `InputCommandRunner` helper +- [x] `OpenCliWriter` (hand-built OpenCLI JSON from registry metadata) +- [x] `IHelpProvider` interface +- [x] `MetadataHelpProvider` (auto-generated from registry) + +### Phase 7: Utilities ✅ +- [x] `TerminalEscapeSanitizer` + +### Phase 8: Built-in Commands 🔲 (deferred — not needed to prove the API) +- [ ] `HelpCommand` (IViewerCommand — TUI mode) +- [ ] `AgentGuideCommand` (IViewerCommand) +- [ ] `EmbeddedMarkdownHelpProvider` +- [ ] `MarkdownRenderer` (depends on TG markdown-to-ANSI API) + +### Phase 9: clet migration ✅ +- [x] Add `` from clet to the library +- [x] Refactor all 14 input clets to `ICliCommand` +- [x] Refactor all 4 viewer clets to `IViewerCommand` +- [x] Rewrite `Program.cs` to use `CliHost` +- [x] Delete 16 superseded files (abstractions, registry, hosting, JSON) +- [x] Migrate `BuiltInClets.RegisterAll` to `ICommandRegistry` +- [x] Migrate `MarkdownHelpRenderer` to library types +- [x] Migrate `MarkdownContentResolver` to `CommandRunOptions` extensions +- [x] Add `RenderCatAsync` to `MarkdownClet` (replaces AliasDispatcher --cat logic) +- [x] Update all test projects (UnitTests, IntegrationTests, ConfigTests, UITests, SmokeTests) +- [x] Delete 8 unit test files now covered by library tests +- [x] All existing tests pass (495 across 6 projects, 0 warnings) + +## Test Summary + +- **95 tests** passing in `Terminal.Gui.Cli.Tests` +- **310 tests** passing in `Clet.UnitTests` +- **54 tests** passing in `Clet.IntegrationTests` (2 skipped: FileDialog under ansi driver) +- **26 tests** passing in `Clet.ConfigTests` +- **14 tests** passing in `Clet.SmokeTests` (1 skipped: deferred to v0.3) +- **0 warnings**, **0 errors** across full solution build (Debug and Release) + +## Decisions & Learnings + +| # | Decision/Learning | Rationale | +|---|---|---| +| 1 | `CommandResult` is a `readonly record struct` (not class) | Matches spec §5.1. Value-type avoids allocations on hot path. Both generic and non-generic in same file since they're tightly coupled record structs. | +| 2 | `ICliCommand` in separate file `ICliCommandGeneric.cs` | One-type-per-file rule. Named distinctly from `ICliCommand.cs`. | +| 3 | ` --help` interception happens BEFORE ArgParser.Parse() | The parser would reject `--help` as an unknown per-command option. Must intercept `args[1] is "help"/"--help"/"-h"` early in CliHost.RunAsync(). | +| 4 | `CliHost` owns the `CommandRegistry` instance (constructs it internally) | Consumers register commands via `host.Registry.Register(cmd)` after construction. This matches spec §5.5 API shape. | +| 5 | Deferred `HelpCommand`/`AgentGuideCommand` TUI viewers | These require Terminal.Gui Views (markdown rendering, interactive help). The API is proven without them; consumers can register their own. `MetadataHelpProvider` handles --help output. | +| 6 | `OpenCliWriter` uses hand-built StringBuilder JSON | AOT-friendly per spec §8.3. No JsonSerializerContext needed for the introspection format. | +| 7 | `--cat` is a framework flag (not per-command option) | ArgParser handles `--cat` at framework level, sets `CommandRunOptions.Cat`. CliHost calls `IViewerCommand.RenderCatAsync` when `Cat=true`. MarkdownClet no longer declares `--cat` in its Options list. | +| 8 | `--no-browse` is a consumer global option (not per-command) | Registered via `CliHostOptions.GlobalOptions`. Accessed via `options.HasExtension("no-browse")`. Same for `--allow-file` (repeatable) and `--allow-binary` (flag). | +| 9 | `list` command removed — use `--opencli` or `--help` | The old `CommandLineRoot` handled `list` inline. CliHost provides `--opencli` (JSON registry dump per OpenCLI spec) and `--help` (human-readable). No separate `list` command needed. | +| 10 | clet keeps its own `InputCletRunner` (wraps library's `InputCommandRunner` concept) | clet's runner adds `CletStyling.BaseSchemeName` which the library deliberately omits (spec §9.1: library doesn't set scheme). clet-specific styling stays internal. | +| 11 | `FileAccessPolicy` and `MarkdownContentResolver` stay internal to clet | These are clet-specific security/content-resolution helpers. They use `CommandRunOptions.Extensions` to access `--allow-file`/`--allow-binary` values. Not promoted to library. | + +## Spec Discrepancies / Feedback + +| # | Issue | Notes | +|---|---|---| +| 1 | Spec §5.5 shows `ReplaceBuiltInCommand` on `CliHostOptions` | Not implemented yet since built-in commands (HelpCommand, AgentGuideCommand) are deferred. Will add when those are implemented. | +| 2 | ~~`MarkdownRenderer` utility deferred~~ | **RESOLVED**: TG #5388 added `Markdown.RenderToAnsi()`. clet now uses it directly in `MarkdownHelpRenderer` (commit `2157025`). | +| 3 | C# `\x` escape greedy parsing | When writing test literals like `"\x1bb"`, the `\x` consumes up to 4 hex digits, so `b` becomes part of the escape. Must use `\u001b` + string concatenation for hex escapes followed by hex characters in tests. | +| 4 | ~~`--cat` should be documented as framework flag in spec~~ | **RESOLVED**: Already present in spec §6.1 table at line 524. | +| 5 | ~~`list` command removal needs spec update~~ | **RESOLVED**: No stale `list` command references exist in the spec. | +| 6 | Consumer global options vs per-command options | The spec doesn't clearly distinguish between options owned by the framework, options registered by the consumer app as globals (like --allow-file), and per-command options. In practice: framework flags are hard-coded in ArgParser; consumer globals go in Extensions; per-command options go in CommandOptions. | + +## Files Created (25 source + 8 test) + +### Library (`src/Terminal.Gui.Cli/`) +- `Terminal.Gui.Cli.csproj` +- `Properties/AssemblyInfo.cs` +- `Abstractions/CommandKind.cs` +- `Abstractions/CommandStatus.cs` +- `Abstractions/CommandOptionDescriptor.cs` +- `Abstractions/CommandResult.cs` +- `Abstractions/ICliCommand.cs` +- `Abstractions/ICliCommandGeneric.cs` +- `Abstractions/IViewerCommand.cs` +- `Abstractions/ICommandRegistry.cs` +- `Abstractions/CommandRunOptions.cs` +- `Registry/CommandRegistry.cs` +- `Hosting/GlobalOptionDescriptor.cs` +- `Hosting/ExitCodes.cs` +- `Hosting/ArgParser.cs` +- `Hosting/CliHostOptions.cs` +- `Hosting/CliHost.cs` +- `Hosting/InputCommandRunner.cs` +- `Output/JsonEnvelope.cs` +- `Output/CliJsonContext.cs` +- `Output/TypeNames.cs` +- `Output/ResultWriter.cs` +- `Output/OpenCliWriter.cs` +- `Help/IHelpProvider.cs` +- `Help/MetadataHelpProvider.cs` +- `Security/TerminalEscapeSanitizer.cs` + +### Tests (`tests/Terminal.Gui.Cli.Tests/`) +- `Terminal.Gui.Cli.Tests.csproj` +- `StubCommand.cs` +- `CommandRegistryTests.cs` +- `ExitCodesTests.cs` +- `TypeNamesTests.cs` +- `JsonEnvelopeTests.cs` +- `TerminalEscapeSanitizerTests.cs` +- `CommandRunOptionsTests.cs` +- `ArgParserTests.cs` +- `CliHostTests.cs` diff --git a/specs/terminal-gui-cli.md b/specs/terminal-gui-cli.md new file mode 100644 index 0000000..c2a2858 --- /dev/null +++ b/specs/terminal-gui-cli.md @@ -0,0 +1,1148 @@ +# Terminal.Gui.Cli; Library Spec (Draft) + +> Extracted from clet's hosting infrastructure. Enables any Terminal.Gui application to expose Views as scriptable CLI commands with typed JSON output, POSIX exit codes, and AI-agent discoverability. + +--- + +## 0. Status & Audience + +**Status:** Proposal / draft spec for discussion. +**Audience:** Terminal.Gui maintainers, clet maintainers, potential consumers (mdv, community TG tools). +**Prerequisite:** Terminal.Gui 2.x GA (currently 2.2.2). + +--- + +## 1. Problem Statement + +clet has built a complete "TUI-as-CLI-command" hosting system: + +- A dynamic command registry with alias lookup +- A hand-rolled argument parser with global + per-command options +- Typed JSON result envelopes (`SchemaV1`) +- POSIX exit codes (0, 1, 2, 65, 74, 130) +- `Application` lifecycle management (create → init → run → dispose) +- Inline vs fullscreen mode selection +- Timeout / cancellation propagation +- Markdown-to-ANSI help rendering +- Terminal escape sanitization for untrusted content +- Output formatting (plain-text vs JSON, file redirection) +- Machine-readable self-description (`--opencli`; OpenCLI format) + +This infrastructure is **not clet-specific in purpose**. Any Terminal.Gui app that wants to expose one or more Views as scriptable CLI commands would need to rebuild all of the above. However, the pieces are not trivially separable today. clet's `CommandLineRoot` (~575 lines) hard-codes clet-specific flags (`--allow-file`, `--allow-binary`, `--no-browse`) inline with framework flags; `IsKnownOptionToken()` enumerates all of them in a single `is` expression; the corresponding properties live directly on `CletRunOptions`. Extraction requires rewriting the parser loop from hard-coded to data-driven (consuming `GlobalOptionDescriptor` registrations at runtime), not merely renaming and moving files. The interfaces and command-to-hosting boundary are clean; the parser internals are not. + +**Goal:** Extract into `Terminal.Gui.Cli` so that a second TG-based CLI (mdv, a config editor, a diagnostic tool, a community project) can get the full hosting pipeline by adding one PackageReference and implementing one interface. + +--- + +## 2. Design Principles + +1. **The library owns the Terminal.Gui lifecycle.** Commands receive `IApplication`; they never call `Application.Create()` or `app.Init()`. The host decides inline vs fullscreen, manages `CancellationToken`, and tears down cleanly. This is the single most important invariant; it's what makes the pipeline correct and commands composable. + +2. **No reflection. NativeAOT from day one.** Source-generated JSON. No attribute scanning. Commands self-describe via interface properties (metadata is data, not annotations). The library ships with `true`. + +3. **Registry is instances, not types.** Commands are registered as constructed objects, not via generic type parameters. This supports dynamic discovery, conditional registration, DI-constructed commands, and plugin loading without requiring a generic type system dance. + +4. **The library defines a minimal set of framework options; consumers extend freely.** The library only owns the options it must know about to drive the pipeline (JSON output, timeout, initial value, display mode). Everything else is consumer-defined. The consumer declares additional global options via `CliHostOptions`; the parser collects them into a typed bag the consumer controls. Per-command options are declared as metadata (`CommandOptionDescriptor`) and parsed into a `Dictionary`. The command is responsible for interpreting its own options from strings. + +5. **The JSON envelope is the contract.** `{ schemaVersion, status, value?, code?, message? }` is the stable wire format. The library controls `schemaVersion`. All consumers of any `Terminal.Gui.Cli`-based tool can rely on the same envelope shape. + +6. **Exit codes are POSIX-conventional.** The library maps `CommandStatus` → exit codes. Consumers don't pick exit codes; the library does. + +7. **`help` and `agent-guide` are registered commands, not framework-level verbs.** The library ships default implementations (`HelpCommand`, `AgentGuideCommand`) and registers them in the registry during host initialization. They are full `IViewerCommand` implementations; `help` in particular supports interactive TUI mode (fullscreen, scrollable) or `--cat` (markdown rendered as ANSI to stdout). Because they are commands, not hard-coded verbs, consumers can replace them by registering their own implementation under the same alias. The registry rejects *duplicate* aliases but the library registers its defaults first; consumers override by removing then re-registering, or by providing a custom `IHelpProvider` that the default `HelpCommand` delegates to. Structured introspection (`--opencli`) is a root flag, not a command; it's intercepted before dispatch like `--help` and `--version`. + +8. **Zero transitive dependencies beyond Terminal.Gui.** The library references `Terminal.Gui` and nothing else. + +--- + +## 3. Engineering Constitution + +This library adopts the gui-cs/Editor constitution's principled approach. The rules below are binding on all contributions (human or agent). + +### Tenets + +- **This is fun.** Levity and humor are welcome; the work is serious but the tone need not be. +- **Principal Engineering excellence.** Contributors strive to be exemplary practitioners: technically fearless, balanced and pragmatic, illuminating complexity, respecting what came before, and having resounding impact. +- **Delightful customer experience.** Customers in priority order: (1) end-users of CLI tools built on this library, (2) human developers consuming the library, (3) AI agents consuming the library, (4) maintainers. +- **This is TG.** The library is an extension of Terminal.Gui, not independent of it. Follow TG conventions. Do not hack around TG limitations; file issues with repros. +- **Performance matters.** CLI startup and dispatch must be fast. No unnecessary allocations on the hot path. +- **.NET citizen.** Leverage latest C# and runtime features. Remain a great citizen of the .NET ecosystem. + +### Architectural Rules + +| Rule | Statement | +|------|-----------| +| C1 | **Library owns the lifecycle.** Only the dispatcher calls `Application.Create()`, `app.Init()`, `app.RunAsync()`, and disposes `IApplication`. Commands never do. | +| C2 | **No reflection, no code-gen at runtime.** Source-generated JSON only. `[IsAotCompatible]` enforced in CI. | +| C3 | **Public API additions come with a spec brief.** New public surface on `CliHost`, `ICliCommand`, or any shipped type requires a brief in the spec before merge. Stopgaps are marked `[Obsolete]` at introduction. | +| C4 | **No unused public/internal APIs.** If a public or internal member exists, something in `src/` or `examples/` must call it. Tests don't count as a consumer. | +| C5 | **All test projects run in parallel; never touch process globals.** Each test gets its own `IApplication` via `Application.Create()`. Tests must not call static `Application.Init()`, must not call `ConfigurationManager.Enable()`, and must not mutate any static state TG reads. The lone exception: classes that genuinely must mutate a process-global opt out via `[CollectionDefinition(..., DisableParallelization = true)]`. | +| C6 | **Exit codes are library-controlled.** Commands return `CommandResult`; the library maps to exit codes. No command may call `Environment.Exit()`. | +| C7 | **JSON envelope is append-only within a major.** New fields may be added; existing fields are never removed or retyped within a schema version. | +| C8 | **Zero warnings.** `dotnet build` must produce zero warnings in both Debug and Release. Fix at source; suppress only with a written justification. | + +### Testing Requirements + +| Tier | Project | Purpose | Parallelism | +|------|---------|---------|-------------| +| Unit | `Terminal.Gui.Cli.Tests` | Parser, registry, envelope, exit codes, sanitizer; no TG init | Full parallel | +| Integration | `Terminal.Gui.Cli.IntegrationTests` | CliHost end-to-end with `Application.Create()`; lifecycle, cancellation, dispatch | Full parallel (per-test `IApplication`) | +| Smoke | `Terminal.Gui.Cli.SmokeTests` | Process-level: spawn example app, verify `--help`, `--opencli`, exit codes | Full parallel (separate processes) | + +Tests run as executables (xUnit v3): `dotnet run --project tests/`. + +### Doc-Update Gate + +Before a PR can merge: + +- Changed CLI surface, exit codes, JSON envelope, or user-visible behavior? → Update this spec. +- Changed test project layout, harness shape, or layer scope? → Update the test tier table above. +- Made a non-obvious design choice? → Document rationale in the PR description. +- Completed a milestone checkbox? → Tick it on the tracking issue. + +--- + +## 4. Package Identity + +``` +Package ID: Terminal.Gui.Cli +Namespace: Terminal.Gui.Cli +TFM: net10.0 +Dependencies: Terminal.Gui >= 2.2.0 +License: MIT +Repository: gui-cs/cli (final home; initially prototyped in gui-cs/clet) +``` + +--- + +## 5. Public API Surface + +### 5.1 Core Abstractions + +```csharp +namespace Terminal.Gui.Cli; + +/// The two kinds of CLI commands the library knows about. +/// Input commands return a typed value; viewer commands are status-only. +/// "Viewer" does not imply read-only or simple; viewer commands can be fully +/// interactive (editor, config manager) but produce no typed result in the envelope. +public enum CommandKind { Input, Viewer } + +/// Outcome status of a command run. +public enum CommandStatus { Ok, Cancelled, Error, NoResult } + +/// Metadata descriptor for a per-command option. +public sealed record CommandOptionDescriptor ( + string Name, + string? ShortName, + Type ValueType, + string Description, + bool Required, + string? DefaultValue); + +/// Non-generic result for dispatch and output formatting. +public readonly record struct CommandResult ( + CommandStatus Status, + object? Value, + string? ErrorCode, + string? ErrorMessage); + +/// Typed result returned by input commands. +public readonly record struct CommandResult ( + CommandStatus Status, + T? Value, + string? ErrorCode, + string? ErrorMessage); +``` + +### 5.2 Command Interfaces + +```csharp +/// +/// A CLI command backed by a Terminal.Gui View. Self-describes its alias, +/// options, and kind. Implemented by consumer apps. +/// +public interface ICliCommand +{ + string PrimaryAlias { get; } + IReadOnlyList Aliases { get; } + string Description { get; } + CommandKind Kind { get; } + Type ResultType { get; } + IReadOnlyList Options { get; } + + /// Whether this command consumes positional arguments. + bool AcceptsPositionalArgs => false; + + /// Validates the --initial value before the TUI starts. + bool TryValidateInitial (string initial, CommandRunOptions options) => true; + + /// Non-generic dispatch entry point. + Task RunAsync ( + IApplication app, + string? initial, + CommandRunOptions options, + CancellationToken cancellationToken); +} + +/// Typed command that returns a value. +public interface ICliCommand : ICliCommand +{ + new Task> RunAsync ( + IApplication app, + string? initial, + CommandRunOptions options, + CancellationToken cancellationToken); + + // Default interface method bridges typed → untyped + async Task ICliCommand.RunAsync ( + IApplication app, string? initial, + CommandRunOptions options, CancellationToken ct) + { + CommandResult r = await RunAsync (app, initial, options, ct); + return new (r.Status, r.Value, r.ErrorCode, r.ErrorMessage); + } +} + +/// +/// Viewer command. Does not return a typed value in the JSON envelope (status-only). +/// Viewers range from simple read-only content display (help, markdown browser) to +/// heavyweight stateful tools (file editor with undo/redo, config manager with +/// validation and save). They are full TUI applications; "viewer" means "no typed +/// result," not "no interactivity." +/// +public interface IViewerCommand : ICliCommand +{ + // Kind is always CommandKind.Viewer; ResultType is typeof(void). + // The JSON envelope returns status only (ok, cancelled, error); no value field. + + /// + /// Renders content to stdout without launching the TUI. Called when --cat is set. + /// Return null to indicate --cat is not supported (dispatcher falls through to + /// normal TUI dispatch). The library skips Application.Create() when this returns + /// a non-null result. + /// + Task RenderCatAsync ( + CommandRunOptions options, + TextWriter stdout, + CancellationToken cancellationToken) => Task.FromResult (null); +} +``` + +### 5.3 Command Registry + +```csharp +/// Manages alias → command lookup. +public interface ICommandRegistry +{ + void Register (ICliCommand command); + bool TryResolve (string alias, out ICliCommand? command); + IReadOnlyCollection All { get; } +} + +/// Default implementation. Case-insensitive, duplicate-rejecting. +public sealed class CommandRegistry : ICommandRegistry { /* ... */ } +``` + +**Bootstrapping note:** `HelpCommand` requires an `ICommandRegistry` parameter (to enumerate commands for rendering). The library constructs it as `new HelpCommand(registry)` and registers it into the same registry. This circular-construction pattern works (clet proves it daily) because the help command reads the registry lazily at invocation time, not at construction. Consumers writing custom help commands should follow the same pattern. + +### 5.4 Run Options + +```csharp +/// +/// Parsed options bag passed to commands. The library populates the framework +/// properties it owns; consumer-defined options flow through the Extensions +/// dictionary. Commands access both via this single type. +/// +public sealed class CommandRunOptions +{ + // --- Framework-owned (the library parses and acts on these) --- + + /// Pre-fill value for the View. + public string? Initial { get; init; } + + /// Title override for TUI chrome. + public string? Title { get; init; } + + /// Whether to emit JSON envelope instead of plain text. + public bool JsonOutput { get; init; } + + /// Cancel after this duration (parsed from --timeout). + public TimeSpan? Timeout { get; init; } + + /// Force fullscreen (input commands default to inline). + public bool Fullscreen { get; init; } + + /// Render markdown as ANSI to stdout instead of launching the interactive viewer. + public bool Cat { get; init; } + + /// Write result to file instead of stdout. + public string? OutputPath { get; init; } + + /// Constrain inline height. + public int? Rows { get; init; } + + /// Positional arguments (after alias, before options). + public IReadOnlyList Arguments { get; init; } = []; + + // --- Per-command options (declared via CommandOptionDescriptor) --- + + /// Per-command option values keyed by option name. + public IReadOnlyDictionary CommandOptions { get; init; } + = new Dictionary (); + + // --- Consumer-defined global options (registered via CliHostOptions) --- + + /// + /// Extensible bag for consumer-registered global options. Keyed by the + /// option name (without leading dashes). Each key maps to a list of values + /// to support repeatable options (e.g. --allow-file path1 --allow-file path2). + /// For boolean flags, the list contains a single empty string (presence = true). + /// Consumers register their options in CliHostOptions.GlobalOptions; the parser + /// accumulates values here on each occurrence. + /// + public IReadOnlyDictionary> Extensions { get; init; } + = new Dictionary> (); + + /// Typed accessor for single-value extension options. + public T? GetExtension (string key, Func parser, T? defaultValue = default) + { + return Extensions.TryGetValue (key, out IReadOnlyList? values) && values.Count > 0 + ? parser (values[^1]) // last wins for single-value options + : defaultValue; + } + + /// Accessor for repeatable extension options (returns all values). + public IReadOnlyList GetExtensionList (string key) + { + return Extensions.TryGetValue (key, out IReadOnlyList? values) + ? values + : []; + } + + /// Boolean flag accessor (present = true). + public bool HasExtension (string key) => Extensions.ContainsKey (key); +} +``` + +### 5.5 CLI Host (the framework entry point) + +```csharp +/// +/// The main entry point. Owns parsing, dispatch, lifecycle, and output. +/// +public sealed class CliHost +{ + public CliHost (Action? configure = null); + + /// The command registry. Register commands before calling RunAsync. + public ICommandRegistry Registry { get; } + + /// Parse args, dispatch, run the TUI, format output, return exit code. + public Task RunAsync ( + string[] args, + CancellationToken cancellationToken = default, + TextWriter? stdout = null, + TextWriter? stderr = null); +} + +/// Configuration options for the host. +public sealed class CliHostOptions +{ + /// Application name shown in --help and --version. + public string ApplicationName { get; set; } = "app"; + + /// Version string (shown in --version output). + public string? Version { get; set; } + + /// Custom help provider. Null = auto-generated from metadata. + public IHelpProvider? HelpProvider { get; set; } + + /// Maximum characters allowed for --initial. Default: 64K. + public int MaxInitialChars { get; set; } = 64 * 1024; + + /// + /// Replace a library-provided built-in command (help, agent-guide) with a + /// consumer-provided implementation. The alias must match a reserved name. + /// The library deregisters its default and registers the replacement. + /// + public void ReplaceBuiltInCommand (string alias, ICliCommand replacement); + + /// + /// Consumer-defined global options. These are parsed by the framework and + /// placed into CommandRunOptions.Extensions. Consumers register them here; + /// commands read them via GetExtension/HasExtension. + /// + public List GlobalOptions { get; } = []; +} + +/// Describes a consumer-defined global option. +public sealed record GlobalOptionDescriptor ( + string Name, + string? ShortName, + string Description, + bool IsFlag, + bool Repeatable = false); +``` + +**Example: clet registers its domain-specific options as extensions:** + +```csharp +CliHost host = new (o => +{ + o.ApplicationName = "clet"; + o.GlobalOptions.Add (new ("allow-file", null, "Permit file access outside cwd", IsFlag: false, Repeatable: true)); + o.GlobalOptions.Add (new ("allow-binary", null, "Permit binary file content", IsFlag: true)); + o.GlobalOptions.Add (new ("no-browse", null, "Disable link navigation in viewers", IsFlag: true)); +}); + +// In a command: +bool noBrowse = options.HasExtension ("no-browse"); +IReadOnlyList allowedFiles = options.GetExtensionList ("allow-file"); +// allowedFiles contains all paths from: --allow-file /tmp --allow-file /home/user/docs +``` + +### 5.6 Help Provider + +```csharp +/// Pluggable help rendering. +public interface IHelpProvider +{ + /// Render root-level --help. Return null to use auto-generated. + string? GetRootHelp (ICommandRegistry registry); + + /// Render per-command help. Return null to use auto-generated. + string? GetCommandHelp (ICliCommand command); +} + +/// +/// Reads embedded .md resources and renders them to ANSI. Batteries-included +/// for apps that ship help as embedded markdown. +/// +public sealed class EmbeddedMarkdownHelpProvider : IHelpProvider +{ + public EmbeddedMarkdownHelpProvider (Assembly resourceAssembly); + /* ... */ +} +``` + +### 5.7 JSON Envelope + +```csharp +/// The stable wire format for CLI output. +public sealed class JsonEnvelope +{ + public int SchemaVersion { get; } = 1; + public string Status { get; init; } + public object? Value { get; init; } + public string? Code { get; init; } + public string? Message { get; init; } + + public static JsonEnvelope Ok (object? value = null); + public static JsonEnvelope Cancelled (); + public static JsonEnvelope Error (string code, string message); + public static JsonEnvelope NoResult (); + + /// Serialize using source-generated context (AOT-safe). + public string ToJson (); +} +``` + +### 5.8 Exit Codes + +```csharp +/// POSIX-conventional exit codes. +public static class ExitCodes +{ + public const int Ok = 0; + public const int NoResult = 1; + public const int UsageError = 2; + public const int ValidationError = 65; // EX_DATAERR (sysexits.h) + public const int IoError = 74; // EX_IOERR + public const int Cancelled = 130; // 128 + SIGINT + + public static int FromResult (CommandResult result); +} +``` + +### 5.9 Utilities (public but non-core) + +```csharp +/// Strips dangerous terminal escape sequences from untrusted content. +public static class TerminalEscapeSanitizer +{ + public static string? Sanitize (string? input); + public static string SanitizeRenderedOutput (string renderedAnsi); +} + +/// +/// Thin wrapper around Terminal.Gui's Markdown view render-to-ANSI API. +/// This utility exists for convenience; it delegates entirely to TG's Markdown class. +/// If TG ships a public static render-to-ANSI method natively, this wrapper becomes +/// a one-line passthrough and may be deprecated in favor of calling TG directly. +/// Prerequisite: TG must expose markdown-to-ANSI rendering (tracked as a TG feature request). +/// +public static class MarkdownRenderer +{ + public static void RenderToAnsi (string markdown, TextWriter output); +} + +/// Maps CLR types to stable wire-format names (string, int, bool, etc.). +public static class TypeNames +{ + public static string WireName (Type type); +} +``` + +--- + +## 6. CLI Grammar (What the Host Parses) + +The library defines a fixed grammar. Consumer apps get this for free: + +``` + [positional...] [options] + help [] [--cat] # library-provided IViewerCommand (interactive TUI or ANSI) + agent-guide [--json] # library-provided IViewerCommand (non-interactive) + --help | -h # root flag (no TUI; writes to stdout) + --version # root flag (no TUI; writes to stdout) + --opencli # root flag (emits OpenCLI JSON document to stdout) +``` + +`help` and `agent-guide` are registered commands (not framework-level verbs). They go through the same dispatch path as consumer commands. `--help`, `--version`, and `--opencli` are root flags intercepted before dispatch. + +### 6.1 Framework-Owned Global Options + +These are built into the library; the parser knows about them intrinsically: + +| Flag | Short | Value | Purpose | +|------|-------|-------|---------| +| `--json` | `-j` | (none) | Output JSON envelope instead of plain text | +| `--initial` | `-i` | `` | Pre-fill the View with a value | +| `--title` | `-t` | `` | Override the title shown in the TUI chrome | +| `--timeout` | (none) | `` | Cancel after duration (30s, 500ms, 1m) | +| `--fullscreen` | `-f` | (none) | Force fullscreen (input commands default to inline) | +| `--cat` | (none) | (none) | Render markdown content as ANSI to stdout instead of launching the interactive viewer | +| `--output` | `-o` | `` | Write result to file instead of stdout | +| `--rows` | `-r` | `` | Constrain inline height | +| `--opencli` | (none) | (none) | Emit OpenCLI JSON document describing all commands and exit (root flag; no dispatch) | + +### 6.2 Consumer-Registered Global Options + +Consumers declare additional global options via `CliHostOptions.GlobalOptions`. The parser recognizes them and populates `CommandRunOptions.Extensions`. Examples from clet: + +| Flag | Value | Purpose | +|------|-------|---------| +| `--allow-file` | `` (repeatable) | Permit file access outside cwd | +| `--allow-binary` | (none; flag) | Permit binary file content | +| `--no-browse` | (none; flag) | Disable link navigation in viewers | + +Any consumer can add their own. The parser rejects unknown options (exit 2) by checking both the framework set, the consumer-registered set, and the dispatched command's per-command descriptors. + +**Parser accumulation for repeatable options:** When `Repeatable = true` on a `GlobalOptionDescriptor`, the parser appends each occurrence to the list in `Extensions`. For example, `--allow-file /tmp --allow-file /home` produces `Extensions["allow-file"] = ["/tmp", "/home"]`. Non-repeatable options with multiple occurrences: last value wins (single-element list). Flags: presence adds an empty-string entry (`Extensions["no-browse"] = [""]`). + +### 6.3 Per-Command Options + +Anything of the form `-- ` not matching a global option is validated against the dispatched command's `Options` descriptors. Unknown options → exit 2. + +### 6.4 Parsing Additions (vs clet today) + +| Feature | Status in clet | Proposed for library | +|---------|----------------|---------------------| +| `--option=value` syntax | Not supported | **Add** (standard POSIX; trivial; applies to both global and per-command options uniformly) | +| `--` separator (end of options) | Not supported | **Add** (everything after `--` is positional) | +| Short option bundling (`-jf`) | Not supported | **Defer** (complex; rarely needed for TUI commands) | +| Environment variable fallback | Not supported | **Defer** (complexity vs benefit unclear) | +| stdin → `--initial` piping | Supported for `md` viewer in clet | **Defer** to v1.1. Standard CLI UX (`echo "value" \| myapp command` equivalent to `--initial`). Requires detecting non-TTY stdin and reading it, which conflicts with TUI keyboard input. Design needs a `--stdin` explicit opt-in flag or pipe detection heuristic. | + +--- + +## 7. Execution Pipeline + +```mermaid +sequenceDiagram + participant Main as Program.Main + participant Host as CliHost + participant Parser as ArgParser + participant Registry as ICommandRegistry + participant Dispatcher as CommandDispatcher + participant TG as Terminal.Gui + participant Command as ICliCommand + participant Writer as ResultWriter + + Main->>Host: RunAsync(args, ct) + Host->>Parser: Parse(args) + Parser-->>Host: (alias, initial, options) | root-command + + alt Root flag (--help, --version, --opencli) + Host->>Writer: Write flag output (no TUI) + Host-->>Main: exit code + else Built-in command (help, agent-guide) + Host->>Registry: TryResolve(alias) + Registry-->>Host: IViewerCommand (library-provided) + Note over Host: Dispatched like any command (TUI or --cat) + else Alias dispatch + Host->>Registry: TryResolve(alias) + Registry-->>Host: ICliCommand + + Host->>Host: Validate --initial (command.TryValidateInitial) + Host->>Host: Build linked CancellationTokenSource (user ct + timeout) + + alt --cat mode (markdown viewer) + Host->>Command: Render markdown as ANSI (no TUI) + Host->>Writer: Write ANSI to stdout + else Normal TUI mode + Host->>Dispatcher: Dispatch(command, initial, options, ct) + Dispatcher->>TG: Application.Create() + Dispatcher->>TG: ConfigurationManager.Enable(All) + Dispatcher->>TG: app.Init() + Dispatcher->>Command: command.RunAsync(app, initial, options, ct) + Command->>TG: app.RunAsync(view, ct) + TG-->>Command: (user interaction completes) + Command-->>Dispatcher: CommandResult + Dispatcher-->>Host: CommandResult + end + + Host->>Writer: Format(result, options.JsonOutput) + Host->>Host: ExitCodes.FromResult(result) + Host-->>Main: exit code + end +``` + +### 7.1 Lifecycle Details + +1. **ConfigurationManager.Enable(All)** is called inside the dispatcher, not by commands. If it throws (corrupt config), the dispatcher falls back to hard-coded defaults; a bad config never prevents a command from running. + +2. **AppModel selection:** Input commands default to `AppModel.Inline`; viewer commands default to `AppModel.FullScreen`. `--fullscreen` forces fullscreen for inputs. This is resolved by the dispatcher before `app.Init()`. + +3. **IApplication disposal** is always in a `using` block inside the dispatcher. Commands cannot leak it. + +4. **Cancellation ordering:** OperationCanceledException from `app.RunAsync` → `CommandStatus.Cancelled`. The library ensures this is caught in the dispatcher, not by the consumer. + +--- + +## 8. AI Agent Discovery (3-Part Model) + +The library adopts a 3-part discovery model (pioneered by gui-cs/tuirec) that gives AI agents progressively deeper context about the tool: + +### 8.1 Part 1: `llms.txt` (Orientation) + +The consumer ships a `llms.txt` file in their repo root (and optionally serves it at `/llms.txt`). This is a brief, plain-text document sized to fit in an LLM context window (~2K chars). It answers: what is this tool, how do I install it, what are the key flags, and where do I go for more. + +The library does not generate this file; the consumer authors it. But the convention is part of the contract: any `Terminal.Gui.Cli`-based tool SHOULD ship `llms.txt`. + +Example structure: + +``` +# myapp + +> One-line description of what the tool does. + +## Install +... + +## Quick start +... + +## AI Agent usage +Run `myapp agent-guide` to get the full usage reference. + +## Key flags +... + +## Links +- Repository: ... +- Agent guide: agent/AGENT-GUIDE.md +``` + +### 8.2 Part 2: ` agent-guide` (Runnable Reference) + +The library ships a default `AgentGuideCommand` (registered as `agent-guide` alongside `HelpCommand`). When invoked, it prints the consumer's full agent-usage guide to stdout as plain text (no TUI). This is the machine-retrievable equivalent of running `--help` but for AI agents; it covers semantics, examples, best practices, and gotchas that `--help` is too terse for. + +The content is an embedded markdown resource supplied by the consumer via `CliHostOptions`: + +```csharp +public sealed class CliHostOptions +{ + // ... existing properties ... + + /// + /// Embedded resource name (or literal content) for the agent-guide verb. + /// When set, ` agent-guide` prints this to stdout. When null, the + /// verb is not registered. + /// + public string? AgentGuide { get; set; } + + /// + /// If true, AgentGuide is treated as an embedded resource name to load + /// from the consumer's assembly. If false, it's literal content. + /// + public bool AgentGuideIsResource { get; set; } = true; +} +``` + +Behavior: +- ` agent-guide` prints the guide to stdout and exits 0. +- ` agent-guide --json` wraps the guide text in the JSON envelope (`{ schemaVersion: 1, status: "ok", value: "..." }`). +- If the consumer does not set `AgentGuide`, the library does **not** register `AgentGuideCommand` in the registry. The alias is simply absent; invoking it produces "unknown command" (exit 2). This is conditional registration, not "registered but errors." + +### 8.3 Part 3: ` --opencli` (Structured Introspection via OpenCLI) + +Every `Terminal.Gui.Cli`-based tool supports the `--opencli` flag, emitting an [OpenCLI Specification](https://github.com/spectreconsole/open-cli) document (JSON). OpenCLI is an emerging standard (inspired by OpenAPI) that defines a platform-agnostic, machine-readable description of a CLI's commands, arguments, options, and exit codes. By conforming to OpenCLI rather than inventing a bespoke introspection format, `Terminal.Gui.Cli` tools are automatically compatible with any OpenCLI-aware tooling (MCP servers, doc generators, auto-completion engines, AI agents). + +```json +{ + "opencli": "0.1", + "info": { + "title": "clet", + "version": "0.11.0", + "description": "Terminal.Gui Views as scriptable CLI commands" + }, + "command": { + "name": "clet", + "commands": [ + { + "name": "select", + "aliases": ["select"], + "description": "Presents a list of options and returns the selected item.", + "interactive": true, + "arguments": [ + { + "name": "items", + "description": "Space-separated list of items to select from", + "required": false, + "arity": { "minimum": 0, "maximum": null } + } + ], + "options": [ + { + "name": "--options", + "aliases": ["-o"], + "description": "Comma-separated list of options to display", + "arguments": [{ "name": "value", "required": true }] + } + ], + "exitCodes": [ + { "code": 0, "description": "Success; selected value in stdout" }, + { "code": 2, "description": "Usage error" }, + { "code": 130, "description": "Cancelled (Esc/Ctrl-C)" } + ], + "metadata": [ + { "name": "kind", "value": "input" }, + { "name": "resultType", "value": "string" } + ] + } + ], + "options": [ + { + "name": "--json", + "aliases": ["-j"], + "description": "Output JSON envelope instead of plain text", + "recursive": true + }, + { + "name": "--initial", + "aliases": ["-i"], + "description": "Pre-fill the View with a value", + "recursive": true, + "arguments": [{ "name": "value", "required": true }] + } + ], + "exitCodes": [ + { "code": 0, "description": "Success" }, + { "code": 2, "description": "Usage error (unknown command, bad option)" }, + { "code": 130, "description": "Cancelled" } + ] + } +} +``` + +**Implementation note:** The OpenCLI document is built at runtime from registry metadata (the same `ICliCommand.Aliases`, `Options`, `Description` etc. that commands already expose). The JSON is hand-built using `StringBuilder` with manual escaping; this is a deliberate AOT-friendliness choice that avoids requiring a `JsonSerializerContext` entry for the introspection format. The OpenCLI schema is simple and stable enough that hand-serialization is correct and maintainable. + +**TG.Cli-specific extensions via `metadata`:** OpenCLI's `metadata` array carries key-value pairs for domain-specific information. `Terminal.Gui.Cli` uses this to convey `kind` (input/viewer) and `resultType` (the JSON wire type name for input commands). Agents that understand `Terminal.Gui.Cli` semantics read these; generic OpenCLI consumers ignore them. + +An agent calls ` --opencli` once per session, caches the result, and knows exactly which commands exist, what options they take, what exit codes to expect, and what result shapes to parse. + +### 8.4 How the 3 Parts Compose + +| Layer | When an agent uses it | What it gets | +|-------|----------------------|--------------| +| `llms.txt` | Pre-loaded into context (repo checkout, web fetch, or tool docs) | Orientation: what the tool is, how to invoke it, pointer to deeper layers | +| `agent-guide` | Called once at start of a task; output cached | Full usage reference: semantics, examples, edge cases, best practices | +| `--opencli` | Called once per session; output cached | Structured OpenCLI document: commands, option schemas, exit codes, result types | + +The three layers are deliberately redundant in different ways. `llms.txt` is human-authored prose optimized for LLM comprehension. `agent-guide` is detailed reference optimized for correctness. `--opencli` is a machine-structured, standards-conformant document optimized for programmatic decision-making. An agent can use any combination depending on its context budget and task. + +--- + +## 9. Input Command Runner Helper + +The library ships an `InputCommandRunner` utility (equivalent to clet's `InputCletRunner`) that handles the common pattern: + +```csharp +public static class InputCommandRunner +{ + /// + /// Standard boilerplate: style the wrapper, bind Enter, run, extract result. + /// + public static Task> RunAsync ( + IApplication app, + RunnableWrapper wrapper, + CommandRunOptions options, + string defaultTitle, + CancellationToken cancellationToken, + Func> resultMapper, + bool addEnterBinding = true) + where TControl : View, new(); +} +``` + +This encapsulates: +- Pre-cancellation check +- Wrapper styling (title, border, width, scheme; see below) +- Enter key binding +- `app.RunAsync(wrapper, ct)` +- OperationCanceledException catch +- Post-cancel check +- Result mapping + +### 9.1 Opinionated Defaults and Consumer Override + +`InputCommandRunner` applies opinionated styling defaults to the wrapper before running it: + +```csharp +// Applied by InputCommandRunner (library defaults): +wrapper.Title = options.Title ?? defaultTitle; +wrapper.Width = Dim.Fill (); +wrapper.BorderStyle = LineStyle.Rounded; +wrapper.Border.Thickness = new Thickness (0, 1, 0, 0); +``` + +These defaults give a consistent look across all `Terminal.Gui.Cli`-based tools. However, consumers must be able to override them without the library providing custom callbacks or events. + +**Override mechanism:** `RunnableWrapper` inherits from `View`, which fires `Initialized` after the View hierarchy is set up. A consumer that wants different styling simply subscribes to `Initialized` on the wrapper before passing it to `InputCommandRunner`: + +```csharp +RunnableWrapper wrapper = new (selector); + +// Override library defaults after they're applied: +wrapper.Initialized += (s, e) => +{ + wrapper.BorderStyle = LineStyle.Double; + wrapper.SchemeName = "MyCustomScheme"; + wrapper.Border.Thickness = new Thickness (1); +}; + +return await InputCommandRunner.RunAsync (app, wrapper, options, "Pick one", ct, resultMapper); +``` + +The library does **not** add new events or callbacks for styling. The standard TG View lifecycle (`Initialized`) is sufficient. This matches how TG applications normally customize Views: set properties before `Init`, or subscribe to `Initialized` for post-init adjustments. + +**Note on scheme names:** clet uses `CletStyling.BaseSchemeName` (resolves to the TG `Schemes.Base` name). The library applies the current TG base scheme by default. Consumers override by setting `wrapper.SchemeName` to any scheme registered in their `ConfigurationManager` setup. + +### 9.2 Example: Minimal Input Command + +Commands that wrap a single TG View reduce to ~20 lines: + +```csharp +public sealed class MyPickerCommand : ICliCommand +{ + // ... metadata properties ... + + public async Task> RunAsync ( + IApplication app, string? initial, CommandRunOptions options, CancellationToken ct) + { + OptionSelector selector = new () { Labels = ParseLabels (options) }; + RunnableWrapper wrapper = new (selector); + + return await InputCommandRunner.RunAsync ( + app, wrapper, options, "Pick one", ct, + result => result is { } idx + ? new (CommandStatus.Ok, selector.Labels[idx.Value], null, null) + : new (CommandStatus.NoResult, null, null, null)); + } +} +``` + +--- + +## 10. What the Library Does NOT Do + +| Concern | Rationale | +|---------|-----------| +| Define concrete commands | Consumer's job. clet ships 18 (14 input commands + 4 viewer commands including a full file editor with undo/redo and a config manager); mdv ships 1; your tool ships N. | +| Ship a general-purpose markdown file viewer | The library ships `HelpCommand` and `AgentGuideCommand` as built-in viewers, but not an arbitrary file-browsing `md` command. Consumers who want `myapp md FILE` register their own `IViewerCommand` handling file resolution, access policy, and glob expansion. The library provides `MarkdownRenderer` as a utility; it does not own file reading. | +| Manage file access policy | Domain-specific to clet (threat model for AI agents). Consumer can add their own. | +| Provide logging | Consumer wires up `ILogger` if they want. Library is silent. | +| Auto-discover commands via reflection/source-gen | Explicit registration only (AOT-safe, transparent). | +| Provide DI container | Consumer constructs commands however they like before registering. | +| Own the ConfigurationManager config file path | Consumer sets `ConfigurationManager.AppName` before calling `RunAsync`. Library calls `Enable(All)` inside the dispatcher. | + +--- + +## 11. Project Structure + +``` +Terminal.Gui.Cli/ +├── Terminal.Gui.Cli.csproj +├── Abstractions/ +│ ICliCommand.cs +│ IViewerCommand.cs +│ ICommandRegistry.cs +│ CommandKind.cs +│ CommandStatus.cs +│ CommandResult.cs (non-generic + generic) +│ CommandRunOptions.cs +│ CommandOptionDescriptor.cs +├── Registry/ +│ CommandRegistry.cs +├── Hosting/ +│ CliHost.cs +│ CliHostOptions.cs +│ ArgParser.cs (extracted from CommandLineRoot) +│ CommandDispatcher.cs (extracted from AliasDispatcher) +│ ExitCodes.cs +│ InputCommandRunner.cs (extracted from InputCletRunner) +├── Output/ +│ ResultWriter.cs (extracted from OutputFormatter) +│ JsonEnvelope.cs (extracted from SchemaV1) +│ CliJsonContext.cs (source-generated) +│ TypeNames.cs (extracted from CletTypeNames) +│ OpenCliWriter.cs (hand-built OpenCLI JSON from registry metadata) +├── Commands/ +│ HelpCommand.cs (IViewerCommand; interactive TUI or --cat) +│ AgentGuideCommand.cs (IViewerCommand; non-interactive) +├── Help/ +│ IHelpProvider.cs +│ MetadataHelpProvider.cs (auto-generated from registry) +│ EmbeddedMarkdownHelpProvider.cs +│ MarkdownRenderer.cs (extracted from MarkdownHelpRenderer) +├── Security/ +│ TerminalEscapeSanitizer.cs +└── Properties/ + AssemblyInfo.cs +``` + +Estimated size: **~3000 lines** (excluding tests). + +--- + +## 12. Test Strategy + +### 12.1 Unit tests (no TG init) + +- ArgParser: edge cases, `--option=value`, `--`, unknown options, timeout parsing +- CommandRegistry: registration, duplicate rejection, case-insensitive lookup +- ResultWriter: JSON envelope correctness, plain-text formatting, file output +- ExitCodes: mapping from every CommandStatus × error code combination +- TypeNames: CLR type → wire name +- TerminalEscapeSanitizer: security cases (OSC, CSI, C1 pairs) +- JsonEnvelope: AOT serialization round-trip + +### 12.2 Integration tests (TG init, no real terminal) + +- CliHost end-to-end with mock commands: verify exit codes, stdout content +- Dispatcher lifecycle: ConfigurationManager failure fallback, cancellation propagation +- InputCommandRunner: pre-cancelled token, timeout, normal completion +- Help rendering: metadata-generated and embedded-markdown paths + +### 12.3 Smoke tests (process-level) + +- A test harness app (`Terminal.Gui.Cli.TestApp`) that registers a few trivial commands +- Spawn the binary, verify `--help`, `--version`, `--opencli`, ` --json --timeout 1s` + +--- + +## 13. Implementation Strategy + +### Stage A: Prove the API in clet (single PR) + +The library is first implemented as a new project **inside the clet repo** at `src/Terminal.Gui.Cli/`. This avoids multi-repo coordination overhead while the API is still fluid: + +1. Create `src/Terminal.Gui.Cli/Terminal.Gui.Cli.csproj` (class library, net10.0, `true`). +2. Extract and rename: abstractions, registry, dispatcher, output formatter, JSON envelope, exit codes, help rendering, sanitizer, `InputCommandRunner`. +3. **Rewrite `ArgParser` from scratch** (not extract). clet's `CommandLineRoot` is hard-coded: `IsKnownOptionToken()` enumerates every flag in a single `is` expression, consumer-specific flags (`--allow-file`, `--allow-binary`, `--no-browse`) are parsed inline alongside framework flags, and their values land directly on `CletRunOptions` properties. The library's `ArgParser` must instead be data-driven: framework-owned flags are a fixed set; consumer-registered flags come from `CliHostOptions.GlobalOptions` (a list of `GlobalOptionDescriptor`); per-command flags come from `CommandOptionDescriptor` metadata. The parser loop iterates registered descriptors rather than hard-coding flag names. This is the single largest extraction cost. +4. All extracted types become `public`. +5. Add `tests/Terminal.Gui.Cli.Tests/` (unit + integration tests for the library in isolation). +6. clet's `src/Clet/` takes a `` to the sibling library. +7. Refactor clet to consume the library: delete duplicated hosting code, adopt `ICliCommand`, `CommandRunOptions`, `CliHost`, etc. +8. All existing clet tests must pass (unit, config, integration, smoke, UI). + +At this stage the library is **not published to NuGet**; it's purely a build-time dependency within the solution. + +### Stage B: Rewrite implementation in gui-cs/cli + +Once the API is proven (clet works, rough edges discovered and fixed), create `gui-cs/cli` as a new repo and **rewrite the implementation from scratch**. The API design (interfaces, types, method signatures, JSON envelope shape, exit codes) carries forward unchanged; only the internal implementation is rewritten. + +**Why rewrite the impl but keep the API?** AI-generated code (which Stage A will largely be) accumulates implementation slop: inconsistent error paths, redundant allocations, unclear variable names, slightly-off abstractions that "work but aren't right." A clean-room re-implementation on a second pass produces tighter, more intentional code because the author now fully understands the problem space. The API surface is already validated by real usage in clet; rewriting it would be churn, not improvement. + +**What "rewrite" means concretely:** +- The public API surface (§5) is copied verbatim (same interfaces, same types, same method signatures). +- The spec (this document) is updated with any lessons learned from Stage A (new rules, adjusted defaults, dropped features that proved unnecessary). +- Every `.cs` file in `src/` is written fresh, not copied from the clet repo. The goal is zero slop: clean control flow, minimal allocations, clear naming, no dead code paths, no "TODO" comments. +- Tests are written fresh against the spec, not ported. This catches spec-vs-implementation drift that ported tests would hide. + +The new repo adopts TG.Editor's CI/CD and release workflow model: + +1. **Repo structure** mirrors TG.Editor: + - `src/Terminal.Gui.Cli/` (the library) + - `tests/Terminal.Gui.Cli.Tests/` + - `tests/Terminal.Gui.Cli.IntegrationTests/` + - `examples/` (minimal example app proving the pipeline) + - `Directory.Build.props` with `` base and `` pin + - `.editorconfig` (shared gui-cs style) + +2. **CI workflow** (`.github/workflows/ci.yml`): + - Matrix: ubuntu-latest, macos-latest, windows-latest + - Steps: restore, build, `dotnet format --verify-no-changes`, run all test projects + - `DisableRealDriverIO=1` for Windows runners (no TTY) + - AOT publish of example app per RID to validate trimming + +3. **Release workflow** (`.github/workflows/release.yml`): + - Triggers: `v*` tag push (stable), `develop` branch push (rolling prerelease) + - Version computation: tag → strip `v`; develop → `.` + - Cross-platform build-and-test matrix (Release config) + - `dotnet pack` → push to NuGet with `--skip-duplicate` + - Upload release archives to GitHub Release + +4. **Prepare-release workflow** (`.github/workflows/prepare-release.yml`): + - Manual dispatch with release_type (beta/rc/stable) and optional version override + - Creates `release/v` branch from develop + - Opens PR targeting main with merge checklist + +5. **Finalize-release workflow** (`.github/workflows/finalize-release.yml`): + - Triggers on release PR merge to main + - Creates annotated tag, GitHub Release, deletes release branch + - Opens back-merge PR (main → develop) + +6. **Downstream notification**: + - On successful publish, dispatch `cli-published` event to `gui-cs/clet` (and any other consumers) so they can rebuild against the new version + +### Stage C: clet migrates to the published package + +Once `gui-cs/cli` publishes its first prerelease to NuGet: + +1. clet replaces the `` with ``. +2. Delete `src/Terminal.Gui.Cli/` and `tests/Terminal.Gui.Cli.Tests/` from the clet repo. +3. `Program.cs` becomes: + +```csharp +using Terminal.Gui.Cli; + +internal static class Program +{ + public static async Task Main (string[] args) + { + CletLogging.Initialize (); + + using CancellationTokenSource cts = new (); + Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel (); }; + + CliHost host = new (o => + { + o.ApplicationName = "clet"; + o.Version = VersionInfo.GetCletVersion (); + o.HelpProvider = new EmbeddedMarkdownHelpProvider (typeof (Program).Assembly); + o.AgentGuide = "AgentGuide.md"; + o.GlobalOptions.Add (new ("allow-file", null, "Permit file access outside cwd", IsFlag: false, Repeatable: true)); + o.GlobalOptions.Add (new ("allow-binary", null, "Permit binary file content", IsFlag: true)); + o.GlobalOptions.Add (new ("no-browse", null, "Disable link navigation in viewers", IsFlag: true)); + }); + + BuiltInClets.RegisterAll (host.Registry); + + return await host.RunAsync (args, cts.Token); + } +} +``` + +4. Each clet class: `IClet` → `ICliCommand`, `CletRunOptions` → `CommandRunOptions`, etc. (mechanical find-replace from Stage A already done). + +### Stage D: Second consumer validates generality + +mdv (or another tool) adds `Terminal.Gui.Cli` and ships a single viewer command. This proves the API is not clet-shaped in ways that don't generalize. Only after this validation does the library move to 1.0 stable. + +--- + +## 14. Versioning & Compatibility + +Versioning follows the TG.Editor model: + +| Concern | Policy | +|---------|--------| +| Version source of truth | `` in `Directory.Build.props` (e.g. `1.0.0-develop`) | +| Develop branch pushes | Append `.` → rolling prerelease (e.g. `1.0.0-develop.42`) | +| Tag pushes (`v*`) | Strip `v` prefix → stable or pre-release version (e.g. `1.0.0`, `1.0.0-rc.1`) | +| Terminal.Gui compatibility | `` property in Directory.Build.props; CI can override via `-p:TerminalGuiVersion=` | +| Library major version | Tied to `schemaVersion`. Library 1.x = schema v1. | +| Library minor version | New features (new global options, new helper methods). | +| Library patch version | Bug fixes, TG compatibility bumps. | +| Breaking changes | Additive only within a major. | +| AOT compatibility | Enforced by `true` and CI AOT-publish of example app. | +| Release process | prepare-release (manual dispatch) → release PR → merge to main → finalize-release (tag + GitHub Release + back-merge) → release workflow (NuGet push + downstream dispatch) | + +--- + +## 15. Risks & Mitigations + +| Risk | Severity | Mitigation | +|------|----------|-----------| +| API shaped too tightly around clet's needs | High | Phase 3 (second consumer) validates generality before 1.0 stable. Ship as prerelease until validated. | +| Parser extraction is a rewrite, not a rename | High | clet's `CommandLineRoot` hard-codes domain flags inline; `ArgParser` must be data-driven. Budget this as new code, not extraction. Mitigate by writing `ArgParser` tests first (TDD against the spec), not by copying clet's parser. | +| `IApplication` API changes in TG 2.x | Medium | Pin floor version. Library's dispatcher wraps the `IApplication` calls, so consumer commands are insulated. | +| `RunnableWrapper` is a TG type that may change | Medium | `InputCommandRunner` wraps it; if TG changes the API, only the runner needs updating, not every consumer command. | +| ConfigurationManager global state pollution | Low | Dispatcher isolates CM calls. On failure, falls back to defaults. Consumer never calls CM directly. | +| Binary size increase for trivial single-command apps | Low | Library is ~3K lines, no transitive deps. Marginal AOT size impact. | +| Premature abstraction (only clet consumes it for months) | Medium | Ship as `-preview` or `-rc`. Don't commit to stable until a second consumer exists. | + +--- + +## 16. Resolved Design Questions + +1. **Where does the repo live?** + + **Decision:** Two-stage approach. First: implemented as `src/Terminal.Gui.Cli/` inside the clet repo (ProjectReference; not published). Once proven, rewritten from scratch in a new `gui-cs/cli` repo that publishes `Terminal.Gui.Cli` to NuGet. The new repo adopts TG.Editor's CI/CD model (ci.yml, release.yml, prepare-release.yml, finalize-release.yml with develop/main branching, version in Directory.Build.props, downstream dispatch). + +2. **Should `InputCommandRunner` reference `RunnableWrapper` directly?** + + **Decision:** Yes. Reference `RunnableWrapper` directly. It's a core TG v2 primitive. The library already requires TG >= 2.2.0. + +3. **Should the library handle `Ctrl+C` / `CancellationTokenSource` creation?** + + **Decision:** No; leave to consumer. `CliHost.RunAsync` accepts a `CancellationToken` parameter. The consumer wires up `Console.CancelKeyPress` in their `Main`. This keeps the library testable (tests pass pre-cancelled tokens). + +4. **Should `--cat` mode be part of the library or a consumer-specific extension?** + + **Decision:** Include it. `--cat` renders markdown content as ANSI escape sequences to stdout (no TUI launched). It only applies to viewer commands whose content is markdown; input commands and non-markdown viewers ignore it. The `MarkdownRenderer` utility is useful standalone. If a viewer doesn't have markdown content, `--cat` is a no-op or returns `CommandStatus.Error`. + +5. **Should structured introspection be a command or a root flag?** + + **Decision:** Root flag (`--opencli`). Structured introspection emits an OpenCLI-conformant JSON document and exits immediately; it has no interactive component and no need for TG lifecycle. `help` and `agent-guide` remain registered commands (`IViewerCommand`). `help` is interactive TUI (or `--cat` for ANSI output); `agent-guide` is non-interactive. Consumer aliases cannot shadow `help` or `agent-guide` by default (registry rejects duplicates on reserved names), but consumers can explicitly replace them via `CliHostOptions.ReplaceBuiltInCommand(alias, instance)`. This matches clet's existing model where `HelpClet` is an `IViewerClet` with full interactive capability (`clet help select` opens a fullscreen viewer; `clet help select --cat` prints ANSI to stdout). + +--- + +## 17. Timeline (Proposed) + +| Stage | Milestone | Criteria | +|-------|-----------|----------| +| A | Prove in clet | `src/Terminal.Gui.Cli/` exists in clet repo; all clet tests pass against it; API stabilizes through real usage | +| B | Rewrite in gui-cs/cli | Clean-room implementation from scratch; CI/CD workflows (ci, release, prepare-release, finalize-release) modeled on TG.Editor; library builds and tests pass on all 3 OS | +| C | clet migrates to NuGet package | clet switches from ProjectReference to PackageReference; `src/Terminal.Gui.Cli/` removed from clet repo | +| D | Second consumer | mdv (or another tool) proves the API generalizes | +| E | 1.0 stable | After ≥2 consumers, ≥1 month of prerelease usage, spec rewritten based on learnings | + +--- + +## 18. Alternative Libraries + +This library solves a specific problem (parse args; launch a TUI; return typed results). It is not a general-purpose CLI framework. For context, here is how it compares to existing options: + +| | System.CommandLine | Spectre.Console.Cli | Cocona | Terminal.Gui.Cli | +|-|-------------------|---------------------|--------|------------------| +| **Purpose** | Parse args; invoke handlers | Parse args; execute logic; print output | Convention-based CLI from methods | Parse args; launch TUI; return typed result | +| **Commands** | Statically declared symbols | Statically declared via `CommandSettings` types | Methods as commands | Dynamically registered instances with metadata | +| **Terminal ownership** | None (consumer prints) | Owns terminal for output formatting | None (consumer prints) | Delegates to Terminal.Gui for TUI rendering | +| **Options** | Typed at compile time | Typed at compile time via attributes | Typed via method parameters | String bags, interpreted by commands at runtime | +| **AOT story** | Good (trimming-safe) | Improving but relies on reflection | Relies on reflection | AOT-first, no reflection | +| **Use case** | Traditional CLI tools | Traditional CLI tools (git, dotnet, etc.) | Simple CLI tools with minimal ceremony | TUI-prompt tools (fzf-like pickers, interactive editors, browsers) | + +If your app does not use Terminal.Gui and does not present a TUI, use one of the general-purpose libraries above. If your app wraps TG Views as CLI commands, use `Terminal.Gui.Cli`. diff --git a/src/Clet/Abstractions/BoxedCletResult.cs b/src/Clet/Abstractions/BoxedCletResult.cs deleted file mode 100644 index b226df7..0000000 --- a/src/Clet/Abstractions/BoxedCletResult.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Clet; - -internal readonly record struct BoxedCletResult ( - CletRunStatus Status, - object? Value, - string? ErrorCode, - string? ErrorMessage); diff --git a/src/Clet/Abstractions/CletKind.cs b/src/Clet/Abstractions/CletKind.cs deleted file mode 100644 index e8a59c7..0000000 --- a/src/Clet/Abstractions/CletKind.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Clet; - -internal enum CletKind { Input, Viewer } diff --git a/src/Clet/Abstractions/CletRunOptions.cs b/src/Clet/Abstractions/CletRunOptions.cs deleted file mode 100644 index 4cd20c0..0000000 --- a/src/Clet/Abstractions/CletRunOptions.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace Clet; - -internal sealed record CletRunOptions -{ - public string? Title { get; init; } - public bool JsonOutput { get; init; } - public TimeSpan? Timeout { get; init; } - public bool Fullscreen { get; init; } - public bool Cat { get; init; } - public string? OutputPath { get; init; } - public int? Rows { get; init; } - public IReadOnlyDictionary? CletOptions { get; init; } - public IReadOnlyList? Arguments { get; init; } - - /// Paths explicitly allowed for file reading (bypasses extension + cwd checks). - public IReadOnlyList? AllowedFiles { get; init; } - - /// When true, binary file content (NUL bytes) is permitted. - public bool AllowBinary { get; init; } - - /// When true, disables browser-mode navigation (back/forward) for viewer clets. - public bool NoBrowse { get; init; } -} diff --git a/src/Clet/Abstractions/CletRunResult.cs b/src/Clet/Abstractions/CletRunResult.cs deleted file mode 100644 index f4a1ab5..0000000 --- a/src/Clet/Abstractions/CletRunResult.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace Clet; - -internal readonly record struct CletRunResult -{ - public CletRunStatus Status { get; init; } - public string? ErrorCode { get; init; } - public string? ErrorMessage { get; init; } -} - -internal readonly record struct CletRunResult -{ - public CletRunStatus Status { get; init; } - public T? Value { get; init; } - public string? ErrorCode { get; init; } - public string? ErrorMessage { get; init; } - - public CletRunResult ToUntyped () => new () { Status = Status, ErrorCode = ErrorCode, ErrorMessage = ErrorMessage }; -} diff --git a/src/Clet/Abstractions/CletRunStatus.cs b/src/Clet/Abstractions/CletRunStatus.cs deleted file mode 100644 index 6b94ff9..0000000 --- a/src/Clet/Abstractions/CletRunStatus.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Clet; - -internal enum CletRunStatus { Ok, Cancelled, Error, NoResult } diff --git a/src/Clet/Abstractions/IClet.cs b/src/Clet/Abstractions/IClet.cs deleted file mode 100644 index 0dfe89d..0000000 --- a/src/Clet/Abstractions/IClet.cs +++ /dev/null @@ -1,53 +0,0 @@ -using Terminal.Gui.App; - -namespace Clet; - -internal interface IClet -{ - string PrimaryAlias { get; } - IReadOnlyList Aliases { get; } - string Description { get; } - CletKind Kind { get; } - Type ResultType { get; } - IReadOnlyList Options { get; } - - /// - /// Whether this clet consumes positional arguments. Defaults to . - /// Clets that accept positional args (e.g. select, multi-select, md) - /// should override this to return . - /// - bool AcceptsPositionalArgs => false; - - /// - /// Validates that the string can be parsed by this clet. - /// Returns if valid (or if the clet accepts any string). - /// Clets with typed parsing (int, date, color, etc.) should override to reject unparseable values. - /// - bool TryValidateInitial (string initial, CletRunOptions _) => true; - - Task RunBoxedAsync ( - IApplication app, - string? input, - CletRunOptions options, - CancellationToken cancellationToken); -} - -internal interface IClet : IClet -{ - Task> RunAsync ( - IApplication app, - string? initial, - CletRunOptions options, - CancellationToken cancellationToken); - - async Task IClet.RunBoxedAsync ( - IApplication app, - string? input, - CletRunOptions options, - CancellationToken cancellationToken) - { - CletRunResult result = await RunAsync (app, input, options, cancellationToken); - - return new (result.Status, result.Value, result.ErrorCode, result.ErrorMessage); - } -} diff --git a/src/Clet/Abstractions/ICletRegistry.cs b/src/Clet/Abstractions/ICletRegistry.cs deleted file mode 100644 index 2f75e67..0000000 --- a/src/Clet/Abstractions/ICletRegistry.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Clet; - -internal interface ICletRegistry -{ - void Register (IClet clet); - bool TryResolve (string alias, out IClet? clet); - IReadOnlyCollection All { get; } -} diff --git a/src/Clet/Abstractions/IViewerClet.cs b/src/Clet/Abstractions/IViewerClet.cs deleted file mode 100644 index 6d7ef83..0000000 --- a/src/Clet/Abstractions/IViewerClet.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Terminal.Gui.App; - -namespace Clet; - -internal interface IViewerClet : IClet -{ - Task RunAsync ( - IApplication app, - string? content, - CletRunOptions options, - CancellationToken cancellationToken); - - async Task IClet.RunBoxedAsync ( - IApplication app, - string? input, - CletRunOptions options, - CancellationToken cancellationToken) - { - CletRunResult result = await RunAsync (app, input, options, cancellationToken); - - return new (result.Status, null, result.ErrorCode, result.ErrorMessage); - } -} diff --git a/src/Clet/Clet.csproj b/src/Clet/Clet.csproj index 041771d..abd3e36 100644 --- a/src/Clet/Clet.csproj +++ b/src/Clet/Clet.csproj @@ -29,6 +29,7 @@ + diff --git a/tests/Clet.UITests/CletUiHarness.cs b/tests/Clet.UITests/CletUiHarness.cs index 9257752..667027e 100644 --- a/tests/Clet.UITests/CletUiHarness.cs +++ b/tests/Clet.UITests/CletUiHarness.cs @@ -2,6 +2,7 @@ using Terminal.Gui.App; using Terminal.Gui.Drivers; using Terminal.Gui.Drawing; +using Terminal.Gui.Cli; using Xunit; namespace Clet.UITests; @@ -19,14 +20,14 @@ internal sealed class CletUiHarness : IAsyncDisposable { private readonly IApplication _app; private readonly CancellationTokenSource _cts; - private readonly Task> _cletTask; + private readonly Task> _cletTask; private readonly string? _initialAnsiSnapshot; private readonly string? _initialTextSnapshot; private CletUiHarness ( IApplication app, CancellationTokenSource cts, - Task> cletTask, + Task> cletTask, string? initialAnsiSnapshot, string? initialTextSnapshot) { @@ -39,37 +40,33 @@ private CletUiHarness ( /// Start a harness for the given input clet. Returns after the initial render has been captured. public static Task> StartAsync ( - IClet clet, + ICliCommand clet, string? initial = null, - CletRunOptions? options = null, + CommandRunOptions? options = null, int width = 60, int height = 10) => StartCoreAsync ( - (app, ct) => clet.RunAsync (app, initial, options ?? new CletRunOptions (), ct), + (app, ct) => clet.RunAsync (app, initial, options ?? new CommandRunOptions (), ct), width, height); /// Start a harness for a viewer clet. Result.Value is always default(T) for viewers. public static Task> StartViewerAsync ( - IViewerClet viewer, + IViewerCommand viewer, string? initial = null, - CletRunOptions? options = null, + CommandRunOptions? options = null, int width = 60, int height = 15) => StartCoreAsync ( async (app, ct) => { - CletRunResult result = await viewer.RunAsync (app, initial, options ?? new CletRunOptions (), ct); - return new CletRunResult - { - Status = result.Status, - ErrorCode = result.ErrorCode, - ErrorMessage = result.ErrorMessage, - }; + CommandResult result = await viewer.RunAsync (app, initial, options ?? new CommandRunOptions (), ct); + + return new CommandResult (result.Status, default, result.ErrorCode, result.ErrorMessage); }, width, height); private static async Task> StartCoreAsync ( - Func>> run, + Func>> run, int width, int height) { @@ -151,7 +148,7 @@ private static async Task> StartCoreAsync ( }; app.Iteration += handler; - Task> task; + Task> task; Console.SetOut (capturedOut); try diff --git a/tests/Clet.UITests/CletUiTests.cs b/tests/Clet.UITests/CletUiTests.cs index 6f6e938..28ee6c5 100644 --- a/tests/Clet.UITests/CletUiTests.cs +++ b/tests/Clet.UITests/CletUiTests.cs @@ -1,3 +1,4 @@ +using Terminal.Gui.Cli; using Xunit; namespace Clet.UITests; @@ -111,10 +112,10 @@ public async Task EditorClet_LoadedMarkdownFile_MatchesAnsiGolden () await using var harness = await CletUiHarness.StartViewerAsync ( new EditorClet (), - options: new CletRunOptions + options: new CommandRunOptions { Arguments = [path], - AllowedFiles = [path], + Extensions = new Dictionary> { ["allow-file"] = new List { path } }, }, width: 120, height: 20); @@ -138,10 +139,10 @@ public async Task EditorClet_LongFilename_DoesNotOverlapHelpMenu () await using var harness = await CletUiHarness.StartViewerAsync ( new EditorClet (), - options: new CletRunOptions + options: new CommandRunOptions { Arguments = [path], - AllowedFiles = [path], + Extensions = new Dictionary> { ["allow-file"] = new List { path } }, }, width: 80, height: 12); @@ -167,7 +168,7 @@ public async Task ConfigClet_InitialRender_MatchesAnsiGolden () [Fact] public async Task HelpClet_InitialRender_MatchesAnsiGolden () { - CletRegistry registry = new (); + CommandRegistry registry = new (); BuiltInClets.RegisterAll (registry); await AssertViewerRenderAsync ( @@ -175,11 +176,11 @@ await AssertViewerRenderAsync ( } private static async Task AssertInputRenderAsync ( - IClet clet, + ICliCommand clet, string goldenName, string _, string? initial = null, - CletRunOptions? options = null, + CommandRunOptions? options = null, int width = 60, int height = 10) { @@ -191,11 +192,11 @@ private static async Task AssertInputRenderAsync ( } private static async Task AssertViewerRenderAsync ( - IViewerClet clet, + IViewerCommand clet, string goldenName, string _, string? initial = null, - CletRunOptions? options = null, + CommandRunOptions? options = null, int width = 60, int height = 15) { @@ -206,8 +207,8 @@ private static async Task AssertViewerRenderAsync ( harness.AssertMatchesAnsiGolden (goldenName); } - private static CletRunOptions Options (string name, string value) - => new () { CletOptions = new Dictionary { [name] = value } }; + private static CommandRunOptions Options (string name, string value) + => new () { CommandOptions = new Dictionary { [name] = value } }; private static IEnumerable AllIndexesOf (string text, string value) { diff --git a/tests/Clet.UITests/Goldens/help.ans b/tests/Clet.UITests/Goldens/help.ans index 672c17f..d06c720 100644 --- a/tests/Clet.UITests/Goldens/help.ans +++ b/tests/Clet.UITests/Goldens/help.ans @@ -9,10 +9,10 @@ ┌─────────────────────────────┬─────────────────────────────┬─────────────────┐░ │ Alias │ Description │ Options │░ ├─────────────────────────────┼─────────────────────────────┼─────────────────┤░ -│ select │ Presents a list of options │ --options, │░ +│ ]8;;clet:help:select\select]8;;\ │ Presents a list of options │ --options, │░ │ │ and returns the text of │ args... │░ │ │ the selected item. │ │░ -│ text, multiline-text, mt │ Prompts for multi-line │ │░ +│ ]8;;clet:help:text\text]8;;\, ]8;;clet:help:multiline-text\multiline-text]8;;\, ]8;;clet:help:mt\mt]8;;\ │ Prompts for multi-line │ │░ │ │ text input using an editor │ │░ │ │ and returns the entered │ │▼  ◄ │ ► │ Esc Quit │ clet diff --git a/tests/Clet.UnitTests/AttributePickerCletTests.cs b/tests/Clet.UnitTests/AttributePickerCletTests.cs index 9491133..af213b3 100644 --- a/tests/Clet.UnitTests/AttributePickerCletTests.cs +++ b/tests/Clet.UnitTests/AttributePickerCletTests.cs @@ -1,3 +1,4 @@ +using Terminal.Gui.Cli; using Xunit; namespace Clet.UnitTests; @@ -17,7 +18,7 @@ public void Kind_IsInput () { AttributePickerClet clet = new (); - Assert.Equal (CletKind.Input, clet.Kind); + Assert.Equal (CommandKind.Input, clet.Kind); } [Fact] @@ -55,7 +56,7 @@ public void Options_IsEmpty () [Fact] public void AcceptsPositionalArgs_IsFalse () { - IClet clet = new AttributePickerClet (); + ICliCommand clet = new AttributePickerClet (); Assert.False (clet.AcceptsPositionalArgs); } diff --git a/tests/Clet.UnitTests/BuiltInCletsTests.cs b/tests/Clet.UnitTests/BuiltInCletsTests.cs index d2d6af7..314cbd0 100644 --- a/tests/Clet.UnitTests/BuiltInCletsTests.cs +++ b/tests/Clet.UnitTests/BuiltInCletsTests.cs @@ -1,3 +1,4 @@ +using Terminal.Gui.Cli; using Xunit; namespace Clet.UnitTests; @@ -7,14 +8,14 @@ public class BuiltInCletsTests [Fact] public void RegisterAll_RegistersSelect () { - ICletRegistry registry = new CletRegistry (); + ICommandRegistry registry = new CommandRegistry (); BuiltInClets.RegisterAll (registry); - Assert.True (registry.TryResolve ("select", out IClet? clet)); + Assert.True (registry.TryResolve ("select", out ICliCommand? clet)); Assert.NotNull (clet); Assert.Equal ("select", clet.PrimaryAlias); - Assert.Equal (CletKind.Input, clet.Kind); + Assert.Equal (CommandKind.Input, clet.Kind); } [Theory] @@ -40,12 +41,12 @@ public void RegisterAll_RegistersSelect () [InlineData ("range")] public void RegisterAll_RegistersInputClet (string alias) { - ICletRegistry registry = new CletRegistry (); + ICommandRegistry registry = new CommandRegistry (); BuiltInClets.RegisterAll (registry); - Assert.True (registry.TryResolve (alias, out IClet? clet)); + Assert.True (registry.TryResolve (alias, out ICliCommand? clet)); Assert.NotNull (clet); - Assert.Equal (CletKind.Input, clet.Kind); + Assert.Equal (CommandKind.Input, clet.Kind); } [Theory] @@ -57,18 +58,18 @@ public void RegisterAll_RegistersInputClet (string alias) [InlineData ("config")] public void RegisterAll_RegistersViewerClet (string alias) { - ICletRegistry registry = new CletRegistry (); + ICommandRegistry registry = new CommandRegistry (); BuiltInClets.RegisterAll (registry); - Assert.True (registry.TryResolve (alias, out IClet? clet)); + Assert.True (registry.TryResolve (alias, out ICliCommand? clet)); Assert.NotNull (clet); - Assert.Equal (CletKind.Viewer, clet.Kind); + Assert.Equal (CommandKind.Viewer, clet.Kind); } [Fact] public void RegisterAll_Registers19Clets () { - CletRegistry registry = new (); + CommandRegistry registry = new (); BuiltInClets.RegisterAll (registry); Assert.Equal (18, registry.All.Count); diff --git a/tests/Clet.UnitTests/Clet.UnitTests.csproj b/tests/Clet.UnitTests/Clet.UnitTests.csproj index 721716f..856d4e4 100644 --- a/tests/Clet.UnitTests/Clet.UnitTests.csproj +++ b/tests/Clet.UnitTests/Clet.UnitTests.csproj @@ -14,6 +14,7 @@ + \ No newline at end of file diff --git a/tests/Clet.UnitTests/CletMetadataTests.cs b/tests/Clet.UnitTests/CletMetadataTests.cs index 6db258f..405e385 100644 --- a/tests/Clet.UnitTests/CletMetadataTests.cs +++ b/tests/Clet.UnitTests/CletMetadataTests.cs @@ -1,5 +1,6 @@ #pragma warning disable xUnit1026 // Theory methods sharing MemberData intentionally ignore unused columns +using Terminal.Gui.Cli; using Xunit; namespace Clet.UnitTests; @@ -11,9 +12,9 @@ namespace Clet.UnitTests; /// public class CletMetadataTests { - private static ICletRegistry SharedRegistry () + private static ICommandRegistry SharedRegistry () { - ICletRegistry registry = new CletRegistry (); + ICommandRegistry registry = new CommandRegistry (); BuiltInClets.RegisterAll (registry); return registry; @@ -21,63 +22,63 @@ private static ICletRegistry SharedRegistry () public static IEnumerable AllCletMetadata () { - ICletRegistry registry = SharedRegistry (); + ICommandRegistry registry = SharedRegistry (); - yield return [new SelectClet (), "select", CletKind.Input, typeof (string), true]; - yield return [new IntClet (), "int", CletKind.Input, typeof (int), false]; - yield return [new DecimalClet (), "decimal", CletKind.Input, typeof (decimal), false]; - yield return [new TextClet (), "text", CletKind.Input, typeof (string), false]; - yield return [new ConfirmClet (), "confirm", CletKind.Input, typeof (bool), false]; - yield return [new ColorClet (), "color", CletKind.Input, typeof (string), false]; - yield return [new DateClet (), "date", CletKind.Input, typeof (string), false]; - yield return [new TimeClet (), "time", CletKind.Input, typeof (string), false]; - yield return [new DurationClet (), "duration", CletKind.Input, typeof (string), false]; - yield return [new MultiSelectClet (), "multi-select", CletKind.Input, typeof (System.Text.Json.Nodes.JsonArray), true]; - yield return [new LinearRangeClet (), "linear-range", CletKind.Input, typeof (System.Text.Json.Nodes.JsonObject), true]; - yield return [new PickFileClet (), "pick-file", CletKind.Input, typeof (System.Text.Json.Nodes.JsonNode), false]; - yield return [new PickDirectoryClet (), "pick-directory", CletKind.Input, typeof (string), false]; - yield return [new MarkdownClet (), "md", CletKind.Viewer, typeof (void), true]; - yield return [new HelpClet (registry), "help", CletKind.Viewer, typeof (void), true]; + yield return [new SelectClet (), "select", CommandKind.Input, typeof (string), true]; + yield return [new IntClet (), "int", CommandKind.Input, typeof (int), false]; + yield return [new DecimalClet (), "decimal", CommandKind.Input, typeof (decimal), false]; + yield return [new TextClet (), "text", CommandKind.Input, typeof (string), false]; + yield return [new ConfirmClet (), "confirm", CommandKind.Input, typeof (bool), false]; + yield return [new ColorClet (), "color", CommandKind.Input, typeof (string), false]; + yield return [new DateClet (), "date", CommandKind.Input, typeof (string), false]; + yield return [new TimeClet (), "time", CommandKind.Input, typeof (string), false]; + yield return [new DurationClet (), "duration", CommandKind.Input, typeof (string), false]; + yield return [new MultiSelectClet (), "multi-select", CommandKind.Input, typeof (System.Text.Json.Nodes.JsonArray), true]; + yield return [new LinearRangeClet (), "linear-range", CommandKind.Input, typeof (System.Text.Json.Nodes.JsonObject), true]; + yield return [new PickFileClet (), "pick-file", CommandKind.Input, typeof (System.Text.Json.Nodes.JsonNode), false]; + yield return [new PickDirectoryClet (), "pick-directory", CommandKind.Input, typeof (string), false]; + yield return [new MarkdownClet (), "md", CommandKind.Viewer, typeof (void), true]; + yield return [new HelpClet (registry), "help", CommandKind.Viewer, typeof (void), true]; } [Theory] [MemberData (nameof (AllCletMetadata))] - internal void PrimaryAlias_MatchesExpected (IClet clet, string expectedAlias, CletKind _, Type __, bool ___) + internal void PrimaryAlias_MatchesExpected (ICliCommand clet, string expectedAlias, CommandKind _, Type __, bool ___) { Assert.Equal (expectedAlias, clet.PrimaryAlias); } [Theory] [MemberData (nameof (AllCletMetadata))] - internal void Kind_MatchesExpected (IClet clet, string _, CletKind expectedKind, Type __, bool ___) + internal void Kind_MatchesExpected (ICliCommand clet, string _, CommandKind expectedKind, Type __, bool ___) { Assert.Equal (expectedKind, clet.Kind); } [Theory] [MemberData (nameof (AllCletMetadata))] - internal void ResultType_MatchesExpected (IClet clet, string _, CletKind __, Type expectedType, bool ___) + internal void ResultType_MatchesExpected (ICliCommand clet, string _, CommandKind __, Type expectedType, bool ___) { Assert.Equal (expectedType, clet.ResultType); } [Theory] [MemberData (nameof (AllCletMetadata))] - internal void Description_IsNotEmpty (IClet clet, string _, CletKind __, Type ___, bool ____) + internal void Description_IsNotEmpty (ICliCommand clet, string _, CommandKind __, Type ___, bool ____) { Assert.NotEmpty (clet.Description); } [Theory] [MemberData (nameof (AllCletMetadata))] - internal void Aliases_ContainsPrimaryAlias (IClet clet, string expectedAlias, CletKind _, Type __, bool ___) + internal void Aliases_ContainsPrimaryAlias (ICliCommand clet, string expectedAlias, CommandKind _, Type __, bool ___) { Assert.Contains (expectedAlias, clet.Aliases); } [Theory] [MemberData (nameof (AllCletMetadata))] - internal void AcceptsPositionalArgs_MatchesExpected (IClet clet, string _, CletKind __, Type ___, bool expectedPositional) + internal void AcceptsPositionalArgs_MatchesExpected (ICliCommand clet, string _, CommandKind __, Type ___, bool expectedPositional) { Assert.Equal (expectedPositional, clet.AcceptsPositionalArgs); } diff --git a/tests/Clet.UnitTests/CletRegistryTests.cs b/tests/Clet.UnitTests/CletRegistryTests.cs deleted file mode 100644 index 96e9a3d..0000000 --- a/tests/Clet.UnitTests/CletRegistryTests.cs +++ /dev/null @@ -1,71 +0,0 @@ -using Xunit; - -namespace Clet.UnitTests; - -public class CletRegistryTests -{ - [Fact] - public void Register_AddsToAll () - { - CletRegistry registry = new (); - SelectClet clet = new (); - - registry.Register (clet); - - Assert.Single (registry.All); - Assert.Same (clet, registry.All.First ()); - } - - [Fact] - public void TryResolve_FindsByAlias () - { - CletRegistry registry = new (); - SelectClet clet = new (); - registry.Register (clet); - - bool found = registry.TryResolve ("select", out IClet? resolved); - - Assert.True (found); - Assert.Same (clet, resolved); - } - - [Fact] - public void TryResolve_CaseInsensitive () - { - CletRegistry registry = new (); - registry.Register (new SelectClet ()); - - bool found = registry.TryResolve ("SELECT", out IClet? resolved); - - Assert.True (found); - Assert.NotNull (resolved); - } - - [Fact] - public void TryResolve_ReturnsFalse_ForUnknownAlias () - { - CletRegistry registry = new (); - - bool found = registry.TryResolve ("unknown", out IClet? resolved); - - Assert.False (found); - Assert.Null (resolved); - } - - [Fact] - public void Register_ThrowsOnDuplicateAlias () - { - CletRegistry registry = new (); - registry.Register (new SelectClet ()); - - Assert.Throws (() => registry.Register (new SelectClet ())); - } - - [Fact] - public void All_IsEmpty_Initially () - { - CletRegistry registry = new (); - - Assert.Empty (registry.All); - } -} diff --git a/tests/Clet.UnitTests/CletRunResultTests.cs b/tests/Clet.UnitTests/CletRunResultTests.cs deleted file mode 100644 index 00274d0..0000000 --- a/tests/Clet.UnitTests/CletRunResultTests.cs +++ /dev/null @@ -1,48 +0,0 @@ -using Xunit; - -namespace Clet.UnitTests; - -public class CletRunResultTests -{ - [Fact] - public void CletRunResult_DefaultStatus_IsOk () - { - CletRunResult result = new () { Status = CletRunStatus.Ok }; - - Assert.Equal (CletRunStatus.Ok, result.Status); - Assert.Null (result.ErrorCode); - Assert.Null (result.ErrorMessage); - } - - [Fact] - public void CletRunResultT_ToUntyped_PreservesStatus () - { - CletRunResult typed = new () { Status = CletRunStatus.Cancelled }; - CletRunResult untyped = typed.ToUntyped (); - - Assert.Equal (CletRunStatus.Cancelled, untyped.Status); - } - - [Fact] - public void CletRunResultT_ToUntyped_PreservesError () - { - CletRunResult typed = new () - { - Status = CletRunStatus.Error, - ErrorCode = "TEST", - ErrorMessage = "Something failed", - }; - CletRunResult untyped = typed.ToUntyped (); - - Assert.Equal ("TEST", untyped.ErrorCode); - Assert.Equal ("Something failed", untyped.ErrorMessage); - } - - [Fact] - public void CletRunResultT_Value_CanBeSet () - { - CletRunResult result = new () { Status = CletRunStatus.Ok, Value = 5 }; - - Assert.Equal (5, result.Value); - } -} diff --git a/tests/Clet.UnitTests/CletTypeNamesTests.cs b/tests/Clet.UnitTests/CletTypeNamesTests.cs deleted file mode 100644 index d2682b3..0000000 --- a/tests/Clet.UnitTests/CletTypeNamesTests.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System.Text.Json.Nodes; -using Xunit; - -namespace Clet.UnitTests; - -public class CletTypeNamesTests -{ - [Theory] - [InlineData (typeof (string), "string")] - [InlineData (typeof (int), "int")] - [InlineData (typeof (long), "int")] - [InlineData (typeof (short), "int")] - [InlineData (typeof (decimal), "decimal")] - [InlineData (typeof (double), "decimal")] - [InlineData (typeof (float), "decimal")] - [InlineData (typeof (bool), "bool")] - [InlineData (typeof (DateTime), "date")] - [InlineData (typeof (DateOnly), "date")] - [InlineData (typeof (TimeOnly), "time")] - [InlineData (typeof (TimeSpan), "duration")] - [InlineData (typeof (void), "none")] - public void WireName_MapsKnownTypes (Type type, string expected) - { - Assert.Equal (expected, CletTypeNames.WireName (type)); - } - - [Fact] - public void WireName_JsonArray_ReturnsArray () - { - Assert.Equal ("array", CletTypeNames.WireName (typeof (JsonArray))); - } - - [Fact] - public void WireName_JsonObject_ReturnsObject () - { - Assert.Equal ("object", CletTypeNames.WireName (typeof (JsonObject))); - } - - [Fact] - public void WireName_JsonNode_ReturnsJson () - { - Assert.Equal ("json", CletTypeNames.WireName (typeof (JsonNode))); - } - - [Fact] - public void WireName_NullableInt_ReturnsInt () - { - Assert.Equal ("int", CletTypeNames.WireName (typeof (int?))); - } - - [Fact] - public void WireName_NullableBool_ReturnsBool () - { - Assert.Equal ("bool", CletTypeNames.WireName (typeof (bool?))); - } - - [Fact] - public void WireName_NullableDateTime_ReturnsDate () - { - Assert.Equal ("date", CletTypeNames.WireName (typeof (DateTime?))); - } - - [Fact] - public void WireName_UnknownType_ReturnsTypeName () - { - Assert.Equal ("Guid", CletTypeNames.WireName (typeof (Guid))); - } -} diff --git a/tests/Clet.UnitTests/ColorCletTests.cs b/tests/Clet.UnitTests/ColorCletTests.cs index f446bf3..10b2ff5 100644 --- a/tests/Clet.UnitTests/ColorCletTests.cs +++ b/tests/Clet.UnitTests/ColorCletTests.cs @@ -1,3 +1,4 @@ +using Terminal.Gui.Cli; using Xunit; namespace Clet.UnitTests; @@ -17,7 +18,7 @@ public void Kind_IsInput () { ColorClet clet = new (); - Assert.Equal (CletKind.Input, clet.Kind); + Assert.Equal (CommandKind.Input, clet.Kind); } [Fact] @@ -55,7 +56,7 @@ public void Options_IsEmpty () [Fact] public void AcceptsPositionalArgs_IsFalse () { - IClet clet = new ColorClet (); + ICliCommand clet = new ColorClet (); Assert.False (clet.AcceptsPositionalArgs); } @@ -69,7 +70,7 @@ public void AcceptsPositionalArgs_IsFalse () public void TryValidateInitial_ValidatesColorString (string initial, bool expected) { ColorClet clet = new (); - CletRunOptions options = new (); + CommandRunOptions options = new (); Assert.Equal (expected, clet.TryValidateInitial (initial, options)); } diff --git a/tests/Clet.UnitTests/CommandLineRootTests.cs b/tests/Clet.UnitTests/CommandLineRootTests.cs deleted file mode 100644 index 21de3e8..0000000 --- a/tests/Clet.UnitTests/CommandLineRootTests.cs +++ /dev/null @@ -1,498 +0,0 @@ -using Xunit; - -namespace Clet.UnitTests; - -public class CommandLineRootTests -{ - private static (CommandLineRoot root, StringWriter stdout, StringWriter stderr) Build () - { - ICletRegistry registry = new CletRegistry (); - BuiltInClets.RegisterAll (registry); - - return (new (registry), new (), new ()); - } - - [Fact] - public async Task NoArgs_PrintsRootHelpAndExitsOk () - { - (CommandLineRoot root, StringWriter stdout, StringWriter stderr) = Build (); - - int exit = await root.InvokeAsync ([], CancellationToken.None, stdout, stderr); - - Assert.Equal (ExitCodes.Ok, exit); - Assert.Contains ("clet", stdout.ToString (), StringComparison.OrdinalIgnoreCase); - Assert.Empty (stderr.ToString ()); - } - - [Fact] - public async Task Help_PrintsRootHelp () - { - (CommandLineRoot root, StringWriter stdout, StringWriter stderr) = Build (); - - int exit = await root.InvokeAsync (["--help"], CancellationToken.None, stdout, stderr); - - Assert.Equal (ExitCodes.Ok, exit); - Assert.Contains ("clet", stdout.ToString (), StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task Version_PrintsCletAndTerminalGuiVersions () - { - (CommandLineRoot root, StringWriter stdout, StringWriter stderr) = Build (); - - int exit = await root.InvokeAsync (["--version"], CancellationToken.None, stdout, stderr); - - Assert.Equal (ExitCodes.Ok, exit); - string output = stdout.ToString (); - Assert.Matches (@"^\d+\.\d+\.\d+(-\S+)? \(Terminal\.Gui \S+\)\s*$", output); - } - - [Fact] - public async Task HelpAlias_KnownAlias_DispatchesHelpViewer () - { - (CommandLineRoot root, StringWriter stdout, StringWriter stderr) = Build (); - using CancellationTokenSource cts = new (); - await cts.CancelAsync (); - - int exit = await root.InvokeAsync (["help", "select"], cts.Token, stdout, stderr); - - // Pre-cancelled token causes the md viewer to return Cancelled immediately, - // proving that help dispatches through the interactive help viewer. - Assert.Equal (ExitCodes.Cancelled, exit); - Assert.Empty (stderr.ToString ()); - } - - [Fact] - public async Task HelpAlias_UnknownAlias_WritesStderrAndExits2 () - { - (CommandLineRoot root, StringWriter stdout, StringWriter stderr) = Build (); - - int exit = await root.InvokeAsync (["help", "nope"], CancellationToken.None, stdout, stderr); - - Assert.Equal (ExitCodes.UsageError, exit); - Assert.Contains ("Unknown alias", stderr.ToString ()); - } - - [Fact] - public async Task List_DefaultText_ListsRegisteredClets () - { - (CommandLineRoot root, StringWriter stdout, StringWriter stderr) = Build (); - - int exit = await root.InvokeAsync (["list"], CancellationToken.None, stdout, stderr); - - Assert.Equal (ExitCodes.Ok, exit); - Assert.Contains ("select", stdout.ToString ()); - } - - [Fact] - public async Task List_Json_EmitsSchemaVersion1 () - { - (CommandLineRoot root, StringWriter stdout, StringWriter stderr) = Build (); - - int exit = await root.InvokeAsync (["list", "--json"], CancellationToken.None, stdout, stderr); - - Assert.Equal (ExitCodes.Ok, exit); - string output = stdout.ToString ().TrimEnd (); - Assert.Contains ("\"schemaVersion\":1", output); - Assert.Contains ("\"alias\":\"select\"", output); - Assert.Contains ("\"kind\":\"input\"", output); - Assert.Contains ("\"resultType\":\"string\"", output); - } - - [Theory] - [InlineData ("100ms", 100)] - [InlineData ("1s", 1000)] - [InlineData ("2m", 120_000)] - public void TryParseTimeout_ValidInput_Parses (string input, int expectedMs) - { - Assert.True (CommandLineRoot.TryParseTimeout (input, out TimeSpan result)); - Assert.Equal (expectedMs, (int)result.TotalMilliseconds); - } - - [Theory] - [InlineData ("")] - [InlineData ("abc")] - [InlineData ("0s")] - [InlineData ("-5s")] - [InlineData ("5x")] - public void TryParseTimeout_InvalidInput_ReturnsFalse (string input) - { - Assert.False (CommandLineRoot.TryParseTimeout (input, out _)); - } - - [Fact] - public async Task UnknownAlias_WritesStderrAndExits2 () - { - (CommandLineRoot root, StringWriter stdout, StringWriter stderr) = Build (); - - int exit = await root.InvokeAsync (["nope"], CancellationToken.None, stdout, stderr); - - Assert.Equal (ExitCodes.UsageError, exit); - Assert.Contains ("unknown alias", stderr.ToString ()); - } - - [Fact] - public async Task Alias_TimeoutMissingValue_ExitsWithUsageError () - { - (CommandLineRoot root, StringWriter stdout, StringWriter stderr) = Build (); - - int exit = await root.InvokeAsync (["select", "--timeout"], CancellationToken.None, stdout, stderr); - - Assert.Equal (ExitCodes.UsageError, exit); - Assert.Contains ("--timeout", stderr.ToString ()); - } - - [Fact] - public async Task Alias_TimeoutInvalidValue_ExitsWithUsageError () - { - (CommandLineRoot root, StringWriter stdout, StringWriter stderr) = Build (); - - int exit = await root.InvokeAsync (["select", "--timeout", "wat"], CancellationToken.None, stdout, stderr); - - Assert.Equal (ExitCodes.UsageError, exit); - } - - [Fact] - public async Task Alias_TitleMissingValue_ExitsWithUsageError () - { - (CommandLineRoot root, StringWriter stdout, StringWriter stderr) = Build (); - - int exit = await root.InvokeAsync (["select", "--title"], CancellationToken.None, stdout, stderr); - - Assert.Equal (ExitCodes.UsageError, exit); - Assert.Contains ("--title", stderr.ToString ()); - } - - [Fact] - public async Task List_ShortJsonFlag_EmitsJson () - { - (CommandLineRoot root, StringWriter stdout, StringWriter stderr) = Build (); - - int exit = await root.InvokeAsync (["list", "-j"], CancellationToken.None, stdout, stderr); - - Assert.Equal (ExitCodes.Ok, exit); - Assert.Contains ("\"schemaVersion\":1", stdout.ToString ()); - } - - [Fact] - public async Task Alias_ShortTitleFlag_MissingValue_ExitsWithUsageError () - { - (CommandLineRoot root, StringWriter stdout, StringWriter stderr) = Build (); - - int exit = await root.InvokeAsync (["select", "-t"], CancellationToken.None, stdout, stderr); - - Assert.Equal (ExitCodes.UsageError, exit); - Assert.Contains ("-t", stderr.ToString ()); - } - - [Fact] - public async Task Alias_ShortInitialFlag_MissingValue_ExitsWithUsageError () - { - (CommandLineRoot root, StringWriter stdout, StringWriter stderr) = Build (); - - int exit = await root.InvokeAsync (["select", "-i"], CancellationToken.None, stdout, stderr); - - Assert.Equal (ExitCodes.UsageError, exit); - Assert.Contains ("--initial", stderr.ToString ()); - } - - [Fact] - public async Task Alias_PromptMissingValue_ExitsWithUsageError () - { - (CommandLineRoot root, StringWriter stdout, StringWriter stderr) = Build (); - - int exit = await root.InvokeAsync (["select", "--prompt"], CancellationToken.None, stdout, stderr); - - Assert.Equal (ExitCodes.UsageError, exit); - Assert.Contains ("--prompt", stderr.ToString ()); - } - - [Fact] - public async Task Alias_ShortPromptFlag_MissingValue_ExitsWithUsageError () - { - (CommandLineRoot root, StringWriter stdout, StringWriter stderr) = Build (); - - int exit = await root.InvokeAsync (["select", "-p"], CancellationToken.None, stdout, stderr); - - Assert.Equal (ExitCodes.UsageError, exit); - Assert.Contains ("-p", stderr.ToString ()); - } - - [Fact] - public async Task Alias_PositionalArgs_NonPositionalClet_ExitsWithUsageError () - { - (CommandLineRoot root, StringWriter stdout, StringWriter stderr) = Build (); - - int exit = await root.InvokeAsync (["int", "42"], CancellationToken.None, stdout, stderr); - - Assert.Equal (ExitCodes.UsageError, exit); - Assert.Contains ("does not accept positional arguments", stderr.ToString ()); - Assert.Contains ("42", stderr.ToString ()); - } - - [Fact] - public async Task Alias_PositionalArgs_NonPositionalClet_SingleArg_ShowsInitialHint () - { - (CommandLineRoot root, StringWriter stdout, StringWriter stderr) = Build (); - - int exit = await root.InvokeAsync (["int", "42"], CancellationToken.None, stdout, stderr); - - Assert.Equal (ExitCodes.UsageError, exit); - string stderrStr = stderr.ToString (); - Assert.Contains ("hint:", stderrStr); - Assert.Contains ("--initial", stderrStr); - Assert.Contains ("42", stderrStr); - } - - [Fact] - public async Task Alias_PositionalArgs_NonPositionalClet_MultipleArgs_NoHint () - { - (CommandLineRoot root, StringWriter stdout, StringWriter stderr) = Build (); - - int exit = await root.InvokeAsync (["int", "foo", "bar"], CancellationToken.None, stdout, stderr); - - Assert.Equal (ExitCodes.UsageError, exit); - string stderrStr = stderr.ToString (); - Assert.Contains ("does not accept positional arguments", stderrStr); - Assert.DoesNotContain ("hint:", stderrStr); - } - - [Fact] - public async Task Alias_PositionalArgs_NonPositionalClet_SingleDash_ExitsWithUsageError () - { - (CommandLineRoot root, StringWriter stdout, StringWriter stderr) = Build (); - - // bare "-" is treated as a positional arg (not a flag), and should be rejected - int exit = await root.InvokeAsync (["int", "-"], CancellationToken.None, stdout, stderr); - - Assert.Equal (ExitCodes.UsageError, exit); - Assert.Contains ("does not accept positional arguments", stderr.ToString ()); - } - - [Fact] - public async Task Alias_RowsMissingValue_ExitsWithUsageError () - { - (CommandLineRoot root, StringWriter stdout, StringWriter stderr) = Build (); - - int exit = await root.InvokeAsync (["select", "--rows"], CancellationToken.None, stdout, stderr); - - Assert.Equal (ExitCodes.UsageError, exit); - Assert.Contains ("--rows", stderr.ToString ()); - } - - [Fact] - public async Task Alias_RowsInvalidValue_ExitsWithUsageError () - { - (CommandLineRoot root, StringWriter stdout, StringWriter stderr) = Build (); - - int exit = await root.InvokeAsync (["select", "--rows", "bad"], CancellationToken.None, stdout, stderr); - - Assert.Equal (ExitCodes.UsageError, exit); - Assert.Contains ("--rows", stderr.ToString ()); - } - - [Fact] - public async Task Alias_RowsZeroValue_ExitsWithUsageError () - { - (CommandLineRoot root, StringWriter stdout, StringWriter stderr) = Build (); - - int exit = await root.InvokeAsync (["select", "--rows", "0"], CancellationToken.None, stdout, stderr); - - Assert.Equal (ExitCodes.UsageError, exit); - } - - [Fact] - public async Task Alias_ShortRowsFlag_MissingValue_ExitsWithUsageError () - { - (CommandLineRoot root, StringWriter stdout, StringWriter stderr) = Build (); - - int exit = await root.InvokeAsync (["select", "-r"], CancellationToken.None, stdout, stderr); - - Assert.Equal (ExitCodes.UsageError, exit); - Assert.Contains ("--rows", stderr.ToString ()); - } - - [Fact] - public async Task Alias_InitialExceeds64KiB_ExitsWithValidationError () - { - (CommandLineRoot root, StringWriter stdout, StringWriter stderr) = Build (); - string oversized = new ('x', CommandLineRoot.MaxInitialChars + 1); - - int exit = await root.InvokeAsync (["select", "--initial", oversized], CancellationToken.None, stdout, stderr); - - Assert.Equal (ExitCodes.ValidationError, exit); - Assert.Contains ("input-too-large", stderr.ToString ()); - } - - [Fact] - public async Task UnknownAlias_WithOversizedInitial_ExitsWithUsageError () - { - (CommandLineRoot root, StringWriter stdout, StringWriter stderr) = Build (); - string oversized = new ('x', CommandLineRoot.MaxInitialChars + 1); - - int exit = await root.InvokeAsync (["nope", "--initial", oversized], CancellationToken.None, stdout, stderr); - - Assert.Equal (ExitCodes.UsageError, exit); - Assert.Contains ("unknown alias", stderr.ToString ()); - Assert.DoesNotContain ("input-too-large", stderr.ToString ()); - } - - [Fact] - public async Task Alias_InitialExceeds64KiB_Json_EmitsErrorEnvelope () - { - (CommandLineRoot root, StringWriter stdout, StringWriter stderr) = Build (); - string oversized = new ('x', CommandLineRoot.MaxInitialChars + 1); - - int exit = await root.InvokeAsync (["select", "--json", "--initial", oversized], CancellationToken.None, stdout, stderr); - - Assert.Equal (ExitCodes.ValidationError, exit); - string json = stdout.ToString ().TrimEnd (); - Assert.Contains ("\"status\":\"error\"", json); - Assert.Contains ("\"code\":\"input-too-large\"", json); - } - - [Fact] - public async Task Alias_InitialAtExactLimit_DoesNotReject () - { - (CommandLineRoot root, StringWriter stdout, StringWriter stderr) = Build (); - string atLimit = new ('x', CommandLineRoot.MaxInitialChars); - - // Use a pre-cancelled token so dispatch exits immediately without starting TUI. - // We're verifying the size cap doesn't trip at the boundary; the actual clet - // result (cancellation) is incidental. - using CancellationTokenSource cts = new (); - await cts.CancelAsync (); - - int exit = await root.InvokeAsync (["select", "--initial", atLimit], cts.Token, stdout, stderr); - - Assert.NotEqual (ExitCodes.ValidationError, exit); - Assert.DoesNotContain ("input-too-large", stderr.ToString ()); - } - - [Theory] - [InlineData ("color", "not-a-color")] - [InlineData ("int", "abc")] - [InlineData ("decimal", "xyz")] - [InlineData ("date", "not-a-date")] - [InlineData ("time", "not-a-time")] - [InlineData ("duration", "not-a-duration")] - [InlineData ("confirm", "maybe")] - // `linear-range` doesn't validate --initial strictly — unmatched labels just produce - // an empty span. No "invalid --initial value" path to test, so it isn't in this list. - public async Task Alias_InvalidInitialValue_ExitsWithUsageError (string alias, string initial) - { - (CommandLineRoot root, StringWriter stdout, StringWriter stderr) = Build (); - - int exit = await root.InvokeAsync ([alias, "--initial", initial], CancellationToken.None, stdout, stderr); - - Assert.Equal (ExitCodes.UsageError, exit); - Assert.Contains ("invalid --initial value", stderr.ToString ()); - } - - [Fact] - public async Task MdCat_WithInitial_RendersToStdoutAndExitsOk () - { - (CommandLineRoot root, StringWriter stdout, StringWriter stderr) = Build (); - - int exit = await root.InvokeAsync (["md", "--cat", "--initial", "# Hello"], CancellationToken.None, stdout, stderr); - - Assert.Equal (ExitCodes.Ok, exit); - Assert.NotEmpty (stdout.ToString ()); - Assert.Empty (stderr.ToString ()); - } - - [Fact] - public async Task MdCat_WithFile_RendersFileToStdout () - { - (CommandLineRoot root, StringWriter stdout, StringWriter stderr) = Build (); - string tempFile = Path.Combine (Path.GetTempPath (), $"clet-test-{Guid.NewGuid ()}.md"); - - try - { - await File.WriteAllTextAsync (tempFile, "# Test File\n\nSome content.", TestContext.Current.CancellationToken); - - int exit = await root.InvokeAsync (["md", "--cat", "--allow-file", tempFile, tempFile], CancellationToken.None, stdout, stderr); - - Assert.Equal (ExitCodes.Ok, exit); - Assert.NotEmpty (stdout.ToString ()); - } - finally - { - File.Delete (tempFile); - } - } - - [Fact] - public async Task MdCat_NoContent_ExitsWithIoError () - { - (CommandLineRoot root, StringWriter stdout, StringWriter stderr) = Build (); - - int exit = await root.InvokeAsync (["md", "--cat"], CancellationToken.None, stdout, stderr); - - // When stdin is redirected (test runner / CI), MarkdownClet reads empty stdin - // and returns "No input received from stdin". When not redirected, it returns - // "No file specified". Both are IO errors. - Assert.Equal (ExitCodes.IoError, exit); - string err = stderr.ToString (); - Assert.True ( - err.Contains ("No file specified") || err.Contains ("No input received from stdin"), - $"Expected IO error message, got: {err}"); - } - - [Fact] - public async Task Alias_OutputMissingValue_ExitsWithUsageError () - { - (CommandLineRoot root, StringWriter stdout, StringWriter stderr) = Build (); - - int exit = await root.InvokeAsync (["select", "--output"], CancellationToken.None, stdout, stderr); - - Assert.Equal (ExitCodes.UsageError, exit); - Assert.Contains ("--output", stderr.ToString ()); - } - - [Fact] - public async Task Alias_ShortOutputMissingValue_ExitsWithUsageError () - { - (CommandLineRoot root, StringWriter stdout, StringWriter stderr) = Build (); - - int exit = await root.InvokeAsync (["select", "-o"], CancellationToken.None, stdout, stderr); - - Assert.Equal (ExitCodes.UsageError, exit); - Assert.Contains ("--output", stderr.ToString ()); - } - - [Fact] - public async Task Alias_UnknownCletOption_ExitsWithUsageErrorWithoutSwallowingNextToken () - { - (CommandLineRoot root, StringWriter stdout, StringWriter stderr) = Build (); - - int exit = await root.InvokeAsync (["text", "--unknown", "--output", "out.txt"], CancellationToken.None, stdout, stderr); - - Assert.Equal (ExitCodes.UsageError, exit); - Assert.Contains ("unknown option '--unknown'", stderr.ToString ()); - } - - [Fact] - public async Task Alias_InitialFollowedByKnownFlag_ExitsWithUsageError () - { - (CommandLineRoot root, StringWriter stdout, StringWriter stderr) = Build (); - - int exit = await root.InvokeAsync (["text", "--initial", "--output", "out.txt"], CancellationToken.None, stdout, stderr); - - Assert.Equal (ExitCodes.UsageError, exit); - Assert.Contains ("--initial requires a value", stderr.ToString ()); - } - - [Fact] - public async Task Alias_KnownCletOption_PassesThrough () - { - (CommandLineRoot root, StringWriter stdout, StringWriter stderr) = Build (); - using CancellationTokenSource cts = new (); - await cts.CancelAsync (); - - int exit = await root.InvokeAsync (["select", "--options", "Apple,Banana"], cts.Token, stdout, stderr); - - Assert.Equal (ExitCodes.Cancelled, exit); - Assert.Empty (stderr.ToString ()); - } -} diff --git a/tests/Clet.UnitTests/ConfirmCletTests.cs b/tests/Clet.UnitTests/ConfirmCletTests.cs index ebf1db1..570f910 100644 --- a/tests/Clet.UnitTests/ConfirmCletTests.cs +++ b/tests/Clet.UnitTests/ConfirmCletTests.cs @@ -1,3 +1,4 @@ +using Terminal.Gui.Cli; using Xunit; namespace Clet.UnitTests; @@ -17,7 +18,7 @@ public void Kind_IsInput () { ConfirmClet clet = new (); - Assert.Equal (CletKind.Input, clet.Kind); + Assert.Equal (CommandKind.Input, clet.Kind); } [Fact] @@ -55,7 +56,7 @@ public void Options_IsEmpty () [Fact] public void AcceptsPositionalArgs_IsFalse () { - IClet clet = new ConfirmClet (); + ICliCommand clet = new ConfirmClet (); Assert.False (clet.AcceptsPositionalArgs); } diff --git a/tests/Clet.UnitTests/DateCletTests.cs b/tests/Clet.UnitTests/DateCletTests.cs index da89c9d..d005d07 100644 --- a/tests/Clet.UnitTests/DateCletTests.cs +++ b/tests/Clet.UnitTests/DateCletTests.cs @@ -1,3 +1,4 @@ +using Terminal.Gui.Cli; using Xunit; namespace Clet.UnitTests; @@ -17,7 +18,7 @@ public void Kind_IsInput () { DateClet clet = new (); - Assert.Equal (CletKind.Input, clet.Kind); + Assert.Equal (CommandKind.Input, clet.Kind); } [Fact] @@ -55,7 +56,7 @@ public void Options_IsEmpty () [Fact] public void AcceptsPositionalArgs_IsFalse () { - IClet clet = new DateClet (); + ICliCommand clet = new DateClet (); Assert.False (clet.AcceptsPositionalArgs); } diff --git a/tests/Clet.UnitTests/DecimalCletTests.cs b/tests/Clet.UnitTests/DecimalCletTests.cs index 577fda4..2aa9a5d 100644 --- a/tests/Clet.UnitTests/DecimalCletTests.cs +++ b/tests/Clet.UnitTests/DecimalCletTests.cs @@ -1,3 +1,4 @@ +using Terminal.Gui.Cli; using Xunit; namespace Clet.UnitTests; @@ -17,7 +18,7 @@ public void Kind_IsInput () { DecimalClet clet = new (); - Assert.Equal (CletKind.Input, clet.Kind); + Assert.Equal (CommandKind.Input, clet.Kind); } [Fact] @@ -65,7 +66,7 @@ public void Options_StepDefault_IsFractional () [Fact] public void AcceptsPositionalArgs_IsFalse () { - IClet clet = new DecimalClet (); + ICliCommand clet = new DecimalClet (); Assert.False (clet.AcceptsPositionalArgs); } diff --git a/tests/Clet.UnitTests/DurationCletTests.cs b/tests/Clet.UnitTests/DurationCletTests.cs index f767ca0..d98ebe8 100644 --- a/tests/Clet.UnitTests/DurationCletTests.cs +++ b/tests/Clet.UnitTests/DurationCletTests.cs @@ -1,3 +1,4 @@ +using Terminal.Gui.Cli; using Xunit; namespace Clet.UnitTests; @@ -17,7 +18,7 @@ public void Kind_IsInput () { DurationClet clet = new (); - Assert.Equal (CletKind.Input, clet.Kind); + Assert.Equal (CommandKind.Input, clet.Kind); } [Fact] @@ -55,7 +56,7 @@ public void Options_IsEmpty () [Fact] public void AcceptsPositionalArgs_IsFalse () { - IClet clet = new DurationClet (); + ICliCommand clet = new DurationClet (); Assert.False (clet.AcceptsPositionalArgs); } diff --git a/tests/Clet.UnitTests/ExitCodesTests.cs b/tests/Clet.UnitTests/ExitCodesTests.cs deleted file mode 100644 index 73edeb6..0000000 --- a/tests/Clet.UnitTests/ExitCodesTests.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Xunit; - -namespace Clet.UnitTests; - -public class ExitCodesTests -{ - [Fact] - public void Constants_MatchSpec () - { - Assert.Equal (0, ExitCodes.Ok); - Assert.Equal (1, ExitCodes.NoResult); - Assert.Equal (2, ExitCodes.UsageError); - Assert.Equal (65, ExitCodes.ValidationError); - Assert.Equal (74, ExitCodes.IoError); - Assert.Equal (130, ExitCodes.Cancelled); - } - - [Theory] - [InlineData ((int)CletRunStatus.Ok, null, 0)] - [InlineData ((int)CletRunStatus.Cancelled, null, 130)] - [InlineData ((int)CletRunStatus.NoResult, null, 1)] - [InlineData ((int)CletRunStatus.Error, "validation", 65)] - [InlineData ((int)CletRunStatus.Error, "input-too-large", 65)] - [InlineData ((int)CletRunStatus.Error, "io", 74)] - [InlineData ((int)CletRunStatus.Error, "anything-else", 2)] - public void FromResult_MapsStatusToExit (int statusInt, string? errorCode, int expected) - { - BoxedCletResult result = new ((CletRunStatus)statusInt, null, errorCode, null); - - Assert.Equal (expected, ExitCodes.FromResult (result)); - } -} diff --git a/tests/Clet.UnitTests/IntCletTests.cs b/tests/Clet.UnitTests/IntCletTests.cs index 4bc04f8..de03f9a 100644 --- a/tests/Clet.UnitTests/IntCletTests.cs +++ b/tests/Clet.UnitTests/IntCletTests.cs @@ -1,3 +1,4 @@ +using Terminal.Gui.Cli; using Xunit; namespace Clet.UnitTests; @@ -17,7 +18,7 @@ public void Kind_IsInput () { IntClet clet = new (); - Assert.Equal (CletKind.Input, clet.Kind); + Assert.Equal (CommandKind.Input, clet.Kind); } [Fact] @@ -57,7 +58,7 @@ public void Options_ContainsStep () [Fact] public void AcceptsPositionalArgs_IsFalse () { - IClet clet = new IntClet (); + ICliCommand clet = new IntClet (); Assert.False (clet.AcceptsPositionalArgs); } @@ -70,7 +71,7 @@ public void AcceptsPositionalArgs_IsFalse () public void TryValidateInitial_ValidatesIntString (string initial, bool expected) { IntClet clet = new (); - CletRunOptions options = new (); + CommandRunOptions options = new (); Assert.Equal (expected, clet.TryValidateInitial (initial, options)); } diff --git a/tests/Clet.UnitTests/LinearRangeCletTests.cs b/tests/Clet.UnitTests/LinearRangeCletTests.cs index d08ec8b..b5a386f 100644 --- a/tests/Clet.UnitTests/LinearRangeCletTests.cs +++ b/tests/Clet.UnitTests/LinearRangeCletTests.cs @@ -1,3 +1,4 @@ +using Terminal.Gui.Cli; using Xunit; namespace Clet.UnitTests; @@ -11,7 +12,7 @@ public void Metadata_AdvertisesAlias () Assert.Equal ("linear-range", clet.PrimaryAlias); Assert.Contains ("linear-range", clet.Aliases); - Assert.Equal (CletKind.Input, clet.Kind); + Assert.Equal (CommandKind.Input, clet.Kind); Assert.Equal (typeof (System.Text.Json.Nodes.JsonObject), clet.ResultType); } @@ -40,14 +41,14 @@ public void AcceptsPositionalArgs_IsTrue () public async Task RunAsync_NoOptions_ReturnsValidationError () { LinearRangeClet clet = new (); - CletRunOptions options = new (); + CommandRunOptions options = new (); using CancellationTokenSource cts = new (); - CletRunResult result = await clet.RunAsync ( + CommandResult result = await clet.RunAsync ( null!, null, options, cts.Token); - Assert.Equal (CletRunStatus.Error, result.Status); + Assert.Equal (CommandStatus.Error, result.Status); Assert.Equal ("validation", result.ErrorCode); Assert.Contains ("requires --options", result.ErrorMessage ?? ""); } @@ -56,18 +57,18 @@ public async Task RunAsync_NoOptions_ReturnsValidationError () public async Task RunAsync_PreCancelled_ReturnsCancelled () { LinearRangeClet clet = new (); - CletRunOptions options = new () + CommandRunOptions options = new () { - CletOptions = new Dictionary { ["options"] = "a,b,c" }, + CommandOptions = new Dictionary { ["options"] = "a,b,c" }, }; using CancellationTokenSource cts = new (); await cts.CancelAsync (); - CletRunResult result = await clet.RunAsync ( + CommandResult result = await clet.RunAsync ( null!, null, options, cts.Token); - Assert.Equal (CletRunStatus.Cancelled, result.Status); + Assert.Equal (CommandStatus.Cancelled, result.Status); } } diff --git a/tests/Clet.UnitTests/MarkdownCletTests.cs b/tests/Clet.UnitTests/MarkdownCletTests.cs index 250baf3..48092bb 100644 --- a/tests/Clet.UnitTests/MarkdownCletTests.cs +++ b/tests/Clet.UnitTests/MarkdownCletTests.cs @@ -1,3 +1,4 @@ +using Terminal.Gui.Cli; using Xunit; namespace Clet.UnitTests; @@ -17,7 +18,7 @@ public void Kind_IsViewer () { MarkdownClet clet = new (); - Assert.Equal (CletKind.Viewer, clet.Kind); + Assert.Equal (CommandKind.Viewer, clet.Kind); } [Fact] @@ -53,17 +54,13 @@ public void Aliases_ContainsMarkdown () } [Fact] - public void Options_ContainsThemeCatAndNoBrowse () + public void Options_ContainsTheme () { MarkdownClet clet = new (); - Assert.Equal (3, clet.Options.Count); + Assert.Single (clet.Options); Assert.Equal ("theme", clet.Options[0].Name); Assert.False (clet.Options[0].Required); - Assert.Equal ("cat", clet.Options[1].Name); - Assert.False (clet.Options[1].Required); - Assert.Equal ("no-browse", clet.Options[2].Name); - Assert.False (clet.Options[2].Required); } [Fact] diff --git a/tests/Clet.UnitTests/MarkdownContentResolverTests.cs b/tests/Clet.UnitTests/MarkdownContentResolverTests.cs index 04f8c65..a09d6c2 100644 --- a/tests/Clet.UnitTests/MarkdownContentResolverTests.cs +++ b/tests/Clet.UnitTests/MarkdownContentResolverTests.cs @@ -1,3 +1,4 @@ +using Terminal.Gui.Cli; using Xunit; namespace Clet.UnitTests; @@ -7,7 +8,7 @@ public class MarkdownContentResolverTests [Fact] public void Resolve_InlineContent_ReturnsContent () { - CletRunOptions options = new (); + CommandRunOptions options = new (); MarkdownContentResolver.ResolveResult result = MarkdownContentResolver.Resolve ("# Hello", options, stdinReader: null); @@ -19,7 +20,7 @@ public void Resolve_InlineContent_ReturnsContent () [Fact] public void Resolve_InlineContent_TakesPriorityOverStdin () { - CletRunOptions options = new (); + CommandRunOptions options = new (); using StringReader stdin = new ("stdin content"); MarkdownContentResolver.ResolveResult result = MarkdownContentResolver.Resolve ("# Inline", options, stdin); @@ -31,7 +32,7 @@ public void Resolve_InlineContent_TakesPriorityOverStdin () [Fact] public void Resolve_Stdin_ReturnsContent () { - CletRunOptions options = new (); + CommandRunOptions options = new (); using StringReader stdin = new ("# From Stdin"); MarkdownContentResolver.ResolveResult result = MarkdownContentResolver.Resolve (null, options, stdin); @@ -44,7 +45,7 @@ public void Resolve_Stdin_ReturnsContent () [Fact] public void Resolve_EmptyStdin_ReturnsError () { - CletRunOptions options = new (); + CommandRunOptions options = new (); using StringReader stdin = new (""); MarkdownContentResolver.ResolveResult result = MarkdownContentResolver.Resolve (null, options, stdin); @@ -56,7 +57,7 @@ public void Resolve_EmptyStdin_ReturnsError () [Fact] public void Resolve_NoSource_ReturnsError () { - CletRunOptions options = new (); + CommandRunOptions options = new (); MarkdownContentResolver.ResolveResult result = MarkdownContentResolver.Resolve (null, options, stdinReader: null); @@ -76,8 +77,12 @@ public void Resolve_FileArgs_ReadsExistingFiles () string file = Path.Combine (tempDir, "test.md"); File.WriteAllText (file, "# Test File"); - // Use AllowedFiles to bypass CWD confinement — avoids process-global CWD race - CletRunOptions options = new () { Arguments = [file], AllowedFiles = [tempDir] }; + // Use allow-file extension to bypass CWD confinement — avoids process-global CWD race + CommandRunOptions options = new () + { + Arguments = [file], + Extensions = new Dictionary> { ["allow-file"] = new List { tempDir } }, + }; MarkdownContentResolver.ResolveResult result = MarkdownContentResolver.Resolve (null, options, stdinReader: null); @@ -94,7 +99,7 @@ public void Resolve_FileArgs_ReadsExistingFiles () [Fact] public void Resolve_FileArgs_NonexistentFile_ReturnsError () { - CletRunOptions options = new () { Arguments = ["/nonexistent/file.md"] }; + CommandRunOptions options = new () { Arguments = ["/nonexistent/file.md"] }; MarkdownContentResolver.ResolveResult result = MarkdownContentResolver.Resolve (null, options, stdinReader: null); @@ -112,7 +117,11 @@ public void Resolve_FileArgs_TakesPriorityOverInline () string file = Path.Combine (tempDir, "priority.md"); File.WriteAllText (file, "# From File"); - CletRunOptions options = new () { Arguments = [file], AllowedFiles = [tempDir] }; + CommandRunOptions options = new () + { + Arguments = [file], + Extensions = new Dictionary> { ["allow-file"] = new List { tempDir } }, + }; MarkdownContentResolver.ResolveResult result = MarkdownContentResolver.Resolve ("# Inline", options, stdinReader: null); diff --git a/tests/Clet.UnitTests/MultiSelectCletTests.cs b/tests/Clet.UnitTests/MultiSelectCletTests.cs index e45a1e7..854a354 100644 --- a/tests/Clet.UnitTests/MultiSelectCletTests.cs +++ b/tests/Clet.UnitTests/MultiSelectCletTests.cs @@ -1,3 +1,4 @@ +using Terminal.Gui.Cli; using Xunit; namespace Clet.UnitTests; @@ -17,7 +18,7 @@ public void Kind_IsInput () { MultiSelectClet clet = new (); - Assert.Equal (CletKind.Input, clet.Kind); + Assert.Equal (CommandKind.Input, clet.Kind); } [Fact] diff --git a/tests/Clet.UnitTests/OutputFormatterTests.cs b/tests/Clet.UnitTests/OutputFormatterTests.cs deleted file mode 100644 index 8f0a9b3..0000000 --- a/tests/Clet.UnitTests/OutputFormatterTests.cs +++ /dev/null @@ -1,308 +0,0 @@ -using System.Text.Json.Nodes; -using Xunit; - -namespace Clet.UnitTests; - -public class OutputFormatterTests -{ - [Fact] - public void Json_OkWithValue_EmitsValueAndStatus () - { - BoxedCletResult result = new (CletRunStatus.Ok, 2, null, null); - StringWriter stdout = new (); - StringWriter stderr = new (); - - OutputFormatter.Write (result, jsonOutput: true, stdout, stderr); - - string line = stdout.ToString ().TrimEnd (); - Assert.Equal ("{\"schemaVersion\":1,\"status\":\"ok\",\"value\":2}", line); - Assert.Empty (stderr.ToString ()); - } - - [Fact] - public void Json_ViewerOk_OmitsValue () - { - BoxedCletResult result = new (CletRunStatus.Ok, null, null, null); - StringWriter stdout = new (); - StringWriter stderr = new (); - - OutputFormatter.Write (result, jsonOutput: true, stdout, stderr); - - Assert.Equal ("{\"schemaVersion\":1,\"status\":\"ok\"}", stdout.ToString ().TrimEnd ()); - } - - [Fact] - public void Json_Cancelled_OmitsValueAndCode () - { - BoxedCletResult result = new (CletRunStatus.Cancelled, null, null, null); - StringWriter stdout = new (); - StringWriter stderr = new (); - - OutputFormatter.Write (result, jsonOutput: true, stdout, stderr); - - Assert.Equal ("{\"schemaVersion\":1,\"status\":\"cancelled\"}", stdout.ToString ().TrimEnd ()); - } - - [Fact] - public void Json_Error_IncludesCodeAndMessage () - { - BoxedCletResult result = new (CletRunStatus.Error, null, "validation", "bad input"); - StringWriter stdout = new (); - StringWriter stderr = new (); - - OutputFormatter.Write (result, jsonOutput: true, stdout, stderr); - - string line = stdout.ToString ().TrimEnd (); - Assert.Contains ("\"status\":\"error\"", line); - Assert.Contains ("\"code\":\"validation\"", line); - Assert.Contains ("\"message\":\"bad input\"", line); - } - - [Fact] - public void Plain_OkWithValue_WritesValueToStdout () - { - BoxedCletResult result = new (CletRunStatus.Ok, "prod", null, null); - StringWriter stdout = new (); - StringWriter stderr = new (); - - OutputFormatter.Write (result, jsonOutput: false, stdout, stderr); - - Assert.Equal ("prod", stdout.ToString ().TrimEnd ()); - Assert.Empty (stderr.ToString ()); - } - - [Fact] - public void Plain_Cancelled_WritesNothing () - { - BoxedCletResult result = new (CletRunStatus.Cancelled, null, null, null); - StringWriter stdout = new (); - StringWriter stderr = new (); - - OutputFormatter.Write (result, jsonOutput: false, stdout, stderr); - - Assert.Empty (stdout.ToString ()); - Assert.Empty (stderr.ToString ()); - } - - [Fact] - public void Plain_Error_WritesToStderr () - { - BoxedCletResult result = new (CletRunStatus.Error, null, "validation", "bad input"); - StringWriter stdout = new (); - StringWriter stderr = new (); - - OutputFormatter.Write (result, jsonOutput: false, stdout, stderr); - - Assert.Empty (stdout.ToString ()); - Assert.Contains ("validation", stderr.ToString ()); - Assert.Contains ("bad input", stderr.ToString ()); - } - - [Fact] - public void Plain_JsonArray_PrintsOneItemPerLine () - { - JsonArray arr = ["Apple", "Banana", "Cherry"]; - BoxedCletResult result = new (CletRunStatus.Ok, arr, null, null); - StringWriter stdout = new (); - StringWriter stderr = new (); - - OutputFormatter.Write (result, jsonOutput: false, stdout, stderr); - - string[] lines = stdout.ToString ().TrimEnd ().Split (Environment.NewLine); - Assert.Equal (3, lines.Length); - Assert.Equal ("Apple", lines[0]); - Assert.Equal ("Banana", lines[1]); - Assert.Equal ("Cherry", lines[2]); - } - - [Fact] - public void Plain_JsonObject_PrintsCompactJson () - { - JsonObject obj = new () { ["low"] = 5, ["high"] = 10 }; - BoxedCletResult result = new (CletRunStatus.Ok, obj, null, null); - StringWriter stdout = new (); - StringWriter stderr = new (); - - OutputFormatter.Write (result, jsonOutput: false, stdout, stderr); - - string output = stdout.ToString ().TrimEnd (); - Assert.Contains ("\"low\":5", output); - Assert.Contains ("\"high\":10", output); - } - - [Fact] - public void Json_JsonArray_IncludesArrayInEnvelope () - { - JsonArray arr = ["A", "C"]; - BoxedCletResult result = new (CletRunStatus.Ok, arr, null, null); - StringWriter stdout = new (); - StringWriter stderr = new (); - - OutputFormatter.Write (result, jsonOutput: true, stdout, stderr); - - string output = stdout.ToString ().TrimEnd (); - Assert.Contains ("\"status\":\"ok\"", output); - Assert.Contains ("\"value\":[\"A\",\"C\"]", output); - } - - [Fact] - public void Json_JsonObject_IncludesObjectInEnvelope () - { - JsonObject obj = new () { ["fg"] = "#ff0000", ["bg"] = "#000000" }; - BoxedCletResult result = new (CletRunStatus.Ok, obj, null, null); - StringWriter stdout = new (); - StringWriter stderr = new (); - - OutputFormatter.Write (result, jsonOutput: true, stdout, stderr); - - string output = stdout.ToString ().TrimEnd (); - Assert.Contains ("\"status\":\"ok\"", output); - Assert.Contains ("\"fg\":\"#ff0000\"", output); - } - - [Fact] - public void OutputPath_WritesToFile_NotStdout () - { - BoxedCletResult result = new (CletRunStatus.Ok, "hello", null, null); - StringWriter stdout = new (); - StringWriter stderr = new (); - string path = Path.Combine (Path.GetTempPath (), $"clet_test_{Guid.NewGuid ()}.txt"); - - try - { - bool success = OutputFormatter.Write (result, jsonOutput: false, stdout, stderr, path); - - Assert.True (success); - Assert.Empty (stdout.ToString ()); - Assert.Equal ("hello", File.ReadAllText (path).TrimEnd ()); - } - finally - { - File.Delete (path); - } - } - - [Fact] - public void OutputPath_ExistingFile_ReturnsFalseAndDoesNotOverwrite () - { - BoxedCletResult result = new (CletRunStatus.Ok, "new", null, null); - StringWriter stdout = new (); - StringWriter stderr = new (); - string path = Path.Combine (Path.GetTempPath (), $"clet_test_{Guid.NewGuid ()}.txt"); - File.WriteAllText (path, "existing"); - - try - { - bool success = OutputFormatter.Write (result, jsonOutput: false, stdout, stderr, path); - - Assert.False (success); - Assert.Empty (stdout.ToString ()); - Assert.Contains ("cannot write to", stderr.ToString ()); - Assert.Equal ("existing", File.ReadAllText (path)); - } - finally - { - File.Delete (path); - } - } - - [Fact] - public void OutputPath_Error_DoesNotCreateFile () - { - BoxedCletResult result = new (CletRunStatus.Error, null, "validation", "bad input"); - StringWriter stdout = new (); - StringWriter stderr = new (); - string path = Path.Combine (Path.GetTempPath (), $"clet_test_{Guid.NewGuid ()}.txt"); - - try - { - bool success = OutputFormatter.Write (result, jsonOutput: false, stdout, stderr, path); - - Assert.True (success); - Assert.False (File.Exists (path)); - Assert.Empty (stdout.ToString ()); - Assert.Contains ("validation", stderr.ToString ()); - Assert.Contains ("bad input", stderr.ToString ()); - } - finally - { - File.Delete (path); - } - } - - [Fact] - public void OutputPath_JsonError_DoesNotCreateFileAndWritesStdoutEnvelope () - { - BoxedCletResult result = new (CletRunStatus.Error, null, "validation", "bad input"); - StringWriter stdout = new (); - StringWriter stderr = new (); - string path = Path.Combine (Path.GetTempPath (), $"clet_test_{Guid.NewGuid ()}.json"); - - try - { - bool success = OutputFormatter.Write (result, jsonOutput: true, stdout, stderr, path); - - Assert.True (success); - Assert.False (File.Exists (path)); - Assert.Contains ("\"status\":\"error\"", stdout.ToString ()); - Assert.Contains ("\"code\":\"validation\"", stdout.ToString ()); - Assert.Empty (stderr.ToString ()); - } - finally - { - File.Delete (path); - } - } - - [Fact] - public void OutputPath_Json_WritesJsonToFile () - { - BoxedCletResult result = new (CletRunStatus.Ok, 42, null, null); - StringWriter stdout = new (); - StringWriter stderr = new (); - string path = Path.Combine (Path.GetTempPath (), $"clet_test_{Guid.NewGuid ()}.json"); - - try - { - bool success = OutputFormatter.Write (result, jsonOutput: true, stdout, stderr, path); - - Assert.True (success); - Assert.Empty (stdout.ToString ()); - string content = File.ReadAllText (path).TrimEnd (); - Assert.Contains ("\"status\":\"ok\"", content); - Assert.Contains ("\"value\":42", content); - } - finally - { - File.Delete (path); - } - } - - [Fact] - public void OutputPath_InvalidPath_ReturnsFalseAndWritesStderr () - { - BoxedCletResult result = new (CletRunStatus.Ok, "hello", null, null); - StringWriter stdout = new (); - StringWriter stderr = new (); - string path = "/nonexistent_dir_xyz/impossible/file.txt"; - - bool success = OutputFormatter.Write (result, jsonOutput: false, stdout, stderr, path); - - Assert.False (success); - Assert.Empty (stdout.ToString ()); - Assert.Contains ("cannot write to", stderr.ToString ()); - } - - [Fact] - public void OutputPath_Null_WritesToStdout () - { - BoxedCletResult result = new (CletRunStatus.Ok, "world", null, null); - StringWriter stdout = new (); - StringWriter stderr = new (); - - bool success = OutputFormatter.Write (result, jsonOutput: false, stdout, stderr, outputPath: null); - - Assert.True (success); - Assert.Equal ("world", stdout.ToString ().TrimEnd ()); - } -} diff --git a/tests/Clet.UnitTests/PickDirectoryCletTests.cs b/tests/Clet.UnitTests/PickDirectoryCletTests.cs index a3ff731..12eea33 100644 --- a/tests/Clet.UnitTests/PickDirectoryCletTests.cs +++ b/tests/Clet.UnitTests/PickDirectoryCletTests.cs @@ -1,3 +1,4 @@ +using Terminal.Gui.Cli; using Xunit; namespace Clet.UnitTests; @@ -17,7 +18,7 @@ public void Kind_IsInput () { PickDirectoryClet clet = new (); - Assert.Equal (CletKind.Input, clet.Kind); + Assert.Equal (CommandKind.Input, clet.Kind); } [Fact] @@ -57,7 +58,7 @@ public void Options_ContainsRoot () [Fact] public void AcceptsPositionalArgs_IsFalse () { - IClet clet = new PickDirectoryClet (); + ICliCommand clet = new PickDirectoryClet (); Assert.False (clet.AcceptsPositionalArgs); } diff --git a/tests/Clet.UnitTests/PickFileCletTests.cs b/tests/Clet.UnitTests/PickFileCletTests.cs index 1b5a4d3..adabe99 100644 --- a/tests/Clet.UnitTests/PickFileCletTests.cs +++ b/tests/Clet.UnitTests/PickFileCletTests.cs @@ -1,3 +1,4 @@ +using Terminal.Gui.Cli; using Xunit; namespace Clet.UnitTests; @@ -17,7 +18,7 @@ public void Kind_IsInput () { PickFileClet clet = new (); - Assert.Equal (CletKind.Input, clet.Kind); + Assert.Equal (CommandKind.Input, clet.Kind); } [Fact] @@ -59,7 +60,7 @@ public void Options_ContainsMultiRootFilter () [Fact] public void AcceptsPositionalArgs_IsFalse () { - IClet clet = new PickFileClet (); + ICliCommand clet = new PickFileClet (); Assert.False (clet.AcceptsPositionalArgs); } diff --git a/tests/Clet.UnitTests/SchemaV1Tests.cs b/tests/Clet.UnitTests/SchemaV1Tests.cs deleted file mode 100644 index 8046fcd..0000000 --- a/tests/Clet.UnitTests/SchemaV1Tests.cs +++ /dev/null @@ -1,118 +0,0 @@ -using System.Text.Json; -using Xunit; - -namespace Clet.UnitTests; - -public class SchemaV1Tests -{ - [Fact] - public void Ok_WithValue_ProducesCorrectJson () - { - var envelope = SchemaV1.Ok (42); - string json = envelope.ToJson (); - - using var doc = JsonDocument.Parse (json); - JsonElement root = doc.RootElement; - - Assert.Equal (1, root.GetProperty ("schemaVersion").GetInt32 ()); - Assert.Equal ("ok", root.GetProperty ("status").GetString ()); - Assert.Equal (42, root.GetProperty ("value").GetInt32 ()); - Assert.False (root.TryGetProperty ("code", out _)); - Assert.False (root.TryGetProperty ("message", out _)); - } - - [Fact] - public void Ok_WithoutValue_HasNoValueField () - { - var envelope = SchemaV1.Ok (); - string json = envelope.ToJson (); - - using var doc = JsonDocument.Parse (json); - JsonElement root = doc.RootElement; - - Assert.Equal ("ok", root.GetProperty ("status").GetString ()); - Assert.False (root.TryGetProperty ("value", out _)); - } - - [Fact] - public void Cancelled_ProducesCorrectJson () - { - var envelope = SchemaV1.Cancelled (); - string json = envelope.ToJson (); - - using var doc = JsonDocument.Parse (json); - JsonElement root = doc.RootElement; - - Assert.Equal (1, root.GetProperty ("schemaVersion").GetInt32 ()); - Assert.Equal ("cancelled", root.GetProperty ("status").GetString ()); - Assert.False (root.TryGetProperty ("value", out _)); - Assert.False (root.TryGetProperty ("code", out _)); - Assert.False (root.TryGetProperty ("message", out _)); - } - - [Fact] - public void Error_ProducesCorrectJson () - { - var envelope = SchemaV1.Error ("INVALID_INPUT", "Bad data"); - string json = envelope.ToJson (); - - using var doc = JsonDocument.Parse (json); - JsonElement root = doc.RootElement; - - Assert.Equal (1, root.GetProperty ("schemaVersion").GetInt32 ()); - Assert.Equal ("error", root.GetProperty ("status").GetString ()); - Assert.Equal ("INVALID_INPUT", root.GetProperty ("code").GetString ()); - Assert.Equal ("Bad data", root.GetProperty ("message").GetString ()); - Assert.False (root.TryGetProperty ("value", out _)); - } - - [Fact] - public void NoResult_ProducesCorrectJson () - { - var envelope = SchemaV1.NoResult (); - string json = envelope.ToJson (); - - using var doc = JsonDocument.Parse (json); - JsonElement root = doc.RootElement; - - Assert.Equal (1, root.GetProperty ("schemaVersion").GetInt32 ()); - Assert.Equal ("no-result", root.GetProperty ("status").GetString ()); - Assert.False (root.TryGetProperty ("value", out _)); - } - - [Fact] - public void Ok_WithStringValue_ProducesCorrectJson () - { - var envelope = SchemaV1.Ok ("hello"); - string json = envelope.ToJson (); - - using var doc = JsonDocument.Parse (json); - JsonElement root = doc.RootElement; - - Assert.Equal ("hello", root.GetProperty ("value").GetString ()); - } - - [Fact] - public void Ok_WithDecimalValue_ProducesCorrectJson () - { - var envelope = SchemaV1.Ok (3.14m); - string json = envelope.ToJson (); - - using var doc = JsonDocument.Parse (json); - JsonElement root = doc.RootElement; - - Assert.Equal (3.14m, root.GetProperty ("value").GetDecimal ()); - } - - [Fact] - public void Ok_WithBoolValue_ProducesCorrectJson () - { - var envelope = SchemaV1.Ok (true); - string json = envelope.ToJson (); - - using var doc = JsonDocument.Parse (json); - JsonElement root = doc.RootElement; - - Assert.True (root.GetProperty ("value").GetBoolean ()); - } -} diff --git a/tests/Clet.UnitTests/SelectCletTests.cs b/tests/Clet.UnitTests/SelectCletTests.cs index 57b7eb9..d0b3599 100644 --- a/tests/Clet.UnitTests/SelectCletTests.cs +++ b/tests/Clet.UnitTests/SelectCletTests.cs @@ -1,3 +1,4 @@ +using Terminal.Gui.Cli; using Xunit; namespace Clet.UnitTests; @@ -17,7 +18,7 @@ public void Kind_IsInput () { SelectClet clet = new (); - Assert.Equal (CletKind.Input, clet.Kind); + Assert.Equal (CommandKind.Input, clet.Kind); } [Fact] diff --git a/tests/Clet.UnitTests/TerminalEscapeSanitizerTests.cs b/tests/Clet.UnitTests/TerminalEscapeSanitizerTests.cs deleted file mode 100644 index f87b722..0000000 --- a/tests/Clet.UnitTests/TerminalEscapeSanitizerTests.cs +++ /dev/null @@ -1,241 +0,0 @@ -using Xunit; - -namespace Clet.UnitTests; - -public class TerminalEscapeSanitizerTests -{ - /// Helper that asserts no dangerous terminal control bytes remain in the string. - private static void AssertEscapeFree (string result) - { - foreach (char c in result) - { - Assert.False (c == '\x1b', $"Result contains ESC (U+001B). Hex dump: {HexDump (result)}"); - Assert.False (c == '\x07', $"Result contains BEL (U+0007). Hex dump: {HexDump (result)}"); - Assert.False (c == '\u009b', $"Result contains 8-bit CSI (U+009B). Hex dump: {HexDump (result)}"); - Assert.False (c == '\u009d', $"Result contains 8-bit OSC (U+009D). Hex dump: {HexDump (result)}"); - } - } - - private static string HexDump (string s) => - string.Join (" ", s.Select (c => $"U+{(int)c:X4}")); - - // --- Sanitize (input filtering) --- - - [Fact] - public void Sanitize_Null_ReturnsNull () - { - Assert.Null (TerminalEscapeSanitizer.Sanitize (null)); - } - - [Fact] - public void Sanitize_Empty_ReturnsEmpty () - { - Assert.Equal (string.Empty, TerminalEscapeSanitizer.Sanitize (string.Empty)); - } - - [Fact] - public void Sanitize_CleanString_Unchanged () - { - const string input = "Hello, world! This is normal markdown."; - - Assert.Equal (input, TerminalEscapeSanitizer.Sanitize (input)); - } - - [Fact] - public void Sanitize_Osc52ClipboardHijack_Stripped () - { - // OSC 52 clipboard write: ESC ] 52;c; BEL - string payload = "\x1b]52;c;SGVsbG8=\x07"; - string result = TerminalEscapeSanitizer.Sanitize (payload)!; - - AssertEscapeFree (result); - } - - [Fact] - public void Sanitize_Osc0WindowTitle_Stripped () - { - // OSC 0 window title: ESC ] 0;evil BEL - string payload = "\x1b]0;evil\x07"; - string result = TerminalEscapeSanitizer.Sanitize (payload)!; - - AssertEscapeFree (result); - } - - [Fact] - public void Sanitize_Osc2WindowTitle_Stripped () - { - // OSC 2 window title: ESC ] 2;evil BEL - string payload = "\x1b]2;evil\x07"; - string result = TerminalEscapeSanitizer.Sanitize (payload)!; - - AssertEscapeFree (result); - } - - [Fact] - public void Sanitize_Osc8LinkSpoofing_Stripped () - { - // OSC 8 hyperlink: ESC ] 8;;https://evil/ BEL click ESC ] 8;; BEL - string payload = "\x1b]8;;https://evil/" + "\x07" + "click\x1b]8;;\x07"; - string result = TerminalEscapeSanitizer.Sanitize (payload)!; - - AssertEscapeFree (result); - Assert.Contains ("click", result); - } - - [Fact] - public void Sanitize_ClearScreenHome_Stripped () - { - // Clear-screen + home: ESC [ 2J ESC [ H - string payload = "\x1b[2J\x1b[H"; - string result = TerminalEscapeSanitizer.Sanitize (payload)!; - - AssertEscapeFree (result); - } - - [Fact] - public void Sanitize_8BitCsi_Stripped () - { - string payload = "before" + "\u009b" + "after"; - string result = TerminalEscapeSanitizer.Sanitize (payload)!; - - AssertEscapeFree (result); - Assert.Contains ("before", result); - Assert.Contains ("after", result); - } - - [Fact] - public void Sanitize_8BitOsc_Stripped () - { - string payload = "before" + "\u009d" + "after"; - string result = TerminalEscapeSanitizer.Sanitize (payload)!; - - AssertEscapeFree (result); - Assert.Contains ("before", result); - Assert.Contains ("after", result); - } - - [Fact] - public void Sanitize_Bel_Stripped () - { - string payload = "alert" + "\x07" + "here"; - string result = TerminalEscapeSanitizer.Sanitize (payload)!; - - AssertEscapeFree (result); - Assert.Equal ("alerthere", result); - } - - [Fact] - public void Sanitize_C1SevenBitPairs_Stripped () - { - // C1 7-bit pairs: ESC @ through ESC _ - string payload = "a\u001b@b\u001b]c\u001b_d"; - string result = TerminalEscapeSanitizer.Sanitize (payload)!; - - AssertEscapeFree (result); - Assert.Equal ("abcd", result); - } - - [Fact] - public void Sanitize_MixedPayloads_AllStripped () - { - // All payloads from the issue combined with normal text - string input = "# Title\n\u001b]52;c;SGVsbG8=" + "\u0007" + "\nParagraph\u001b]0;evil\u0007\n" - + "\u001b]8;;https://evil/" + "\u0007" + "link\u001b]8;;\u0007\n" - + "\u001b[2J\u001b[H" + "\u009b" + "0m" + "\u009d" + "0;bad\u0007"; - string result = TerminalEscapeSanitizer.Sanitize (input)!; - - AssertEscapeFree (result); - Assert.Contains ("# Title", result); - Assert.Contains ("Paragraph", result); - Assert.Contains ("link", result); - } - - // --- SanitizeRenderedOutput (output filtering) --- - - [Fact] - public void SanitizeRenderedOutput_PreservesSgrSequences () - { - // SGR bold + red: ESC[1m ESC[31m - string input = "\u001b[1mBold\u001b[31mRed\u001b[0m"; - string result = TerminalEscapeSanitizer.SanitizeRenderedOutput (input); - - Assert.Equal (input, result); - } - - [Fact] - public void SanitizeRenderedOutput_PreservesCursorSequences () - { - // Cursor movement: ESC[H, ESC[2J - string input = "\u001b[H\u001b[2J"; - string result = TerminalEscapeSanitizer.SanitizeRenderedOutput (input); - - Assert.Equal (input, result); - } - - [Fact] - public void SanitizeRenderedOutput_StripsOsc () - { - // OSC sequence (ESC ]) should be stripped from rendered output - string input = "text\u001b]0;evil\u0007more"; - string result = TerminalEscapeSanitizer.SanitizeRenderedOutput (input); - - Assert.False (result.Contains ('\x07'), "Result contains BEL"); - Assert.Contains ("text", result); - Assert.Contains ("more", result); - } - - [Fact] - public void SanitizeRenderedOutput_Strips8BitCsi () - { - string input = "text" + "\u009b" + "0mmore"; - string result = TerminalEscapeSanitizer.SanitizeRenderedOutput (input); - - Assert.False (result.Contains ('\u009b'), "Result contains 8-bit CSI"); - } - - [Fact] - public void SanitizeRenderedOutput_Strips8BitOsc () - { - string input = "text" + "\u009d" + "0;evil\u0007more"; - string result = TerminalEscapeSanitizer.SanitizeRenderedOutput (input); - - Assert.False (result.Contains ('\u009d'), "Result contains 8-bit OSC"); - Assert.False (result.Contains ('\x07'), "Result contains BEL"); - } - - [Fact] - public void SanitizeRenderedOutput_StripsBel () - { - string input = "alert" + "\u0007" + "here"; - string result = TerminalEscapeSanitizer.SanitizeRenderedOutput (input); - - Assert.False (result.Contains ('\x07'), "Result contains BEL"); - Assert.Equal ("alerthere", result); - } - - [Fact] - public void SanitizeRenderedOutput_Empty_ReturnsEmpty () - { - Assert.Equal (string.Empty, TerminalEscapeSanitizer.SanitizeRenderedOutput (string.Empty)); - } - - [Fact] - public void SanitizeRenderedOutput_Null_ReturnsEmpty () - { - Assert.Equal (string.Empty, TerminalEscapeSanitizer.SanitizeRenderedOutput (null!)); - } - - [Fact] - public void SanitizeRenderedOutput_MixedLegitAndDangerous () - { - // Mix of legitimate SGR and dangerous OSC - string input = "\u001b[1mBold\u001b]0;evil\u0007Normal\u001b[0m"; - string result = TerminalEscapeSanitizer.SanitizeRenderedOutput (input); - - Assert.Contains ("\u001b[1m", result); // SGR preserved - Assert.Contains ("\u001b[0m", result); // SGR reset preserved - Assert.False (result.Contains ('\x07'), "Result contains BEL"); - Assert.Contains ("Bold", result); - Assert.Contains ("Normal", result); - } -} diff --git a/tests/Clet.UnitTests/TextCletTests.cs b/tests/Clet.UnitTests/TextCletTests.cs index 30339da..05e52b2 100644 --- a/tests/Clet.UnitTests/TextCletTests.cs +++ b/tests/Clet.UnitTests/TextCletTests.cs @@ -1,3 +1,4 @@ +using Terminal.Gui.Cli; using Xunit; namespace Clet.UnitTests; @@ -17,7 +18,7 @@ public void Kind_IsInput () { TextClet clet = new (); - Assert.Equal (CletKind.Input, clet.Kind); + Assert.Equal (CommandKind.Input, clet.Kind); } [Fact] @@ -71,7 +72,7 @@ public void Options_IsEmpty () [Fact] public void AcceptsPositionalArgs_IsFalse () { - IClet clet = new TextClet (); + ICliCommand clet = new TextClet (); Assert.False (clet.AcceptsPositionalArgs); } diff --git a/tests/Clet.UnitTests/TimeCletTests.cs b/tests/Clet.UnitTests/TimeCletTests.cs index f8032a5..ca60b05 100644 --- a/tests/Clet.UnitTests/TimeCletTests.cs +++ b/tests/Clet.UnitTests/TimeCletTests.cs @@ -1,3 +1,4 @@ +using Terminal.Gui.Cli; using Xunit; namespace Clet.UnitTests; @@ -17,7 +18,7 @@ public void Kind_IsInput () { TimeClet clet = new (); - Assert.Equal (CletKind.Input, clet.Kind); + Assert.Equal (CommandKind.Input, clet.Kind); } [Fact] @@ -55,7 +56,7 @@ public void Options_IsEmpty () [Fact] public void AcceptsPositionalArgs_IsFalse () { - IClet clet = new TimeClet (); + ICliCommand clet = new TimeClet (); Assert.False (clet.AcceptsPositionalArgs); } diff --git a/tests/Terminal.Gui.Cli.Tests/ArgParserTests.cs b/tests/Terminal.Gui.Cli.Tests/ArgParserTests.cs new file mode 100644 index 0000000..19c6c4f --- /dev/null +++ b/tests/Terminal.Gui.Cli.Tests/ArgParserTests.cs @@ -0,0 +1,329 @@ +using Xunit; + +namespace Terminal.Gui.Cli.Tests; + +public class ArgParserTests +{ + private static ArgParser CreateParser (params GlobalOptionDescriptor[] globals) + { + return new ArgParser ([.. globals]); + } + + [Fact] + public void EmptyArgs_ReturnsHelpRootFlag () + { + ArgParser parser = CreateParser (); + ArgParser.ParseResult result = parser.Parse ([]); + + Assert.True (result.Success); + Assert.Equal (ArgParser.RootFlag.Help, result.RootFlag); + } + + [Fact] + public void Help_Flag_Detected () + { + ArgParser parser = CreateParser (); + + Assert.Equal (ArgParser.RootFlag.Help, parser.Parse (["--help"]).RootFlag); + Assert.Equal (ArgParser.RootFlag.Help, parser.Parse (["-h"]).RootFlag); + } + + [Fact] + public void Version_Flag_Detected () + { + ArgParser parser = CreateParser (); + + Assert.Equal (ArgParser.RootFlag.Version, parser.Parse (["--version"]).RootFlag); + } + + [Fact] + public void OpenCli_Flag_Detected () + { + ArgParser parser = CreateParser (); + + Assert.Equal (ArgParser.RootFlag.OpenCli, parser.Parse (["--opencli"]).RootFlag); + } + + [Fact] + public void Alias_IsParsed () + { + ArgParser parser = CreateParser (); + ArgParser.ParseResult result = parser.Parse (["select"]); + + Assert.True (result.Success); + Assert.Equal ("select", result.Alias); + } + + [Fact] + public void JsonFlag_SetsJsonOutput () + { + ArgParser parser = CreateParser (); + ArgParser.ParseResult result = parser.Parse (["select", "--json"]); + + Assert.True (result.Options!.JsonOutput); + } + + [Fact] + public void JsonFlag_Short () + { + ArgParser parser = CreateParser (); + ArgParser.ParseResult result = parser.Parse (["select", "-j"]); + + Assert.True (result.Options!.JsonOutput); + } + + [Fact] + public void FullscreenFlag () + { + ArgParser parser = CreateParser (); + ArgParser.ParseResult result = parser.Parse (["select", "--fullscreen"]); + + Assert.True (result.Options!.Fullscreen); + } + + [Fact] + public void CatFlag () + { + ArgParser parser = CreateParser (); + ArgParser.ParseResult result = parser.Parse (["md", "--cat"]); + + Assert.True (result.Options!.Cat); + } + + [Fact] + public void Initial_WithValue () + { + ArgParser parser = CreateParser (); + ArgParser.ParseResult result = parser.Parse (["select", "--initial", "hello"]); + + Assert.True (result.Success); + Assert.Equal ("hello", result.Options!.Initial); + } + + [Fact] + public void Initial_Short () + { + ArgParser parser = CreateParser (); + ArgParser.ParseResult result = parser.Parse (["select", "-i", "hello"]); + + Assert.Equal ("hello", result.Options!.Initial); + } + + [Fact] + public void Initial_MissingValue_Fails () + { + ArgParser parser = CreateParser (); + ArgParser.ParseResult result = parser.Parse (["select", "--initial"]); + + Assert.False (result.Success); + Assert.Contains ("--initial requires a value", result.Error); + } + + [Fact] + public void Title_WithValue () + { + ArgParser parser = CreateParser (); + ArgParser.ParseResult result = parser.Parse (["select", "--title", "Pick one"]); + + Assert.Equal ("Pick one", result.Options!.Title); + } + + [Fact] + public void Timeout_Seconds () + { + ArgParser parser = CreateParser (); + ArgParser.ParseResult result = parser.Parse (["select", "--timeout", "30s"]); + + Assert.Equal (TimeSpan.FromSeconds (30), result.Options!.Timeout); + } + + [Fact] + public void Timeout_Milliseconds () + { + ArgParser parser = CreateParser (); + ArgParser.ParseResult result = parser.Parse (["select", "--timeout", "500ms"]); + + Assert.Equal (TimeSpan.FromMilliseconds (500), result.Options!.Timeout); + } + + [Fact] + public void Timeout_Minutes () + { + ArgParser parser = CreateParser (); + ArgParser.ParseResult result = parser.Parse (["select", "--timeout", "2m"]); + + Assert.Equal (TimeSpan.FromMinutes (2), result.Options!.Timeout); + } + + [Fact] + public void Timeout_Invalid_Fails () + { + ArgParser parser = CreateParser (); + ArgParser.ParseResult result = parser.Parse (["select", "--timeout", "abc"]); + + Assert.False (result.Success); + Assert.Contains ("invalid --timeout", result.Error); + } + + [Fact] + public void Output_WithValue () + { + ArgParser parser = CreateParser (); + ArgParser.ParseResult result = parser.Parse (["select", "--output", "/tmp/out.txt"]); + + Assert.Equal ("/tmp/out.txt", result.Options!.OutputPath); + } + + [Fact] + public void Rows_WithValue () + { + ArgParser parser = CreateParser (); + ArgParser.ParseResult result = parser.Parse (["select", "--rows", "10"]); + + Assert.Equal (10, result.Options!.Rows); + } + + [Fact] + public void Rows_Invalid_Fails () + { + ArgParser parser = CreateParser (); + ArgParser.ParseResult result = parser.Parse (["select", "--rows", "0"]); + + Assert.False (result.Success); + Assert.Contains ("invalid --rows", result.Error); + } + + [Fact] + public void PositionalArgs_Collected () + { + ArgParser parser = CreateParser (); + ArgParser.ParseResult result = parser.Parse (["select", "foo", "bar"]); + + Assert.Equal (["foo", "bar"], result.Options!.Arguments); + } + + [Fact] + public void DoubleDash_EndsOptions () + { + ArgParser parser = CreateParser (); + ArgParser.ParseResult result = parser.Parse (["select", "--", "--json", "foo"]); + + Assert.False (result.Options!.JsonOutput); + Assert.Equal (["--json", "foo"], result.Options!.Arguments); + } + + [Fact] + public void EqualsSign_Syntax () + { + ArgParser parser = CreateParser (); + ArgParser.ParseResult result = parser.Parse (["select", "--initial=hello"]); + + Assert.Equal ("hello", result.Options!.Initial); + } + + [Fact] + public void ConsumerGlobalOption_Flag () + { + GlobalOptionDescriptor noBrowse = new ("no-browse", null, "Disable browsing", IsFlag: true); + ArgParser parser = CreateParser (noBrowse); + ArgParser.ParseResult result = parser.Parse (["md", "--no-browse"]); + + Assert.True (result.Options!.HasExtension ("no-browse")); + } + + [Fact] + public void ConsumerGlobalOption_Value () + { + GlobalOptionDescriptor allowFile = new ("allow-file", null, "Permit file", IsFlag: false, Repeatable: true); + ArgParser parser = CreateParser (allowFile); + ArgParser.ParseResult result = parser.Parse (["edit", "--allow-file", "/tmp"]); + + IReadOnlyList files = result.Options!.GetExtensionList ("allow-file"); + Assert.Single (files); + Assert.Equal ("/tmp", files[0]); + } + + [Fact] + public void ConsumerGlobalOption_Repeatable_Accumulates () + { + GlobalOptionDescriptor allowFile = new ("allow-file", null, "Permit file", IsFlag: false, Repeatable: true); + ArgParser parser = CreateParser (allowFile); + ArgParser.ParseResult result = parser.Parse (["edit", "--allow-file", "/tmp", "--allow-file", "/home"]); + + IReadOnlyList files = result.Options!.GetExtensionList ("allow-file"); + Assert.Equal (2, files.Count); + Assert.Equal ("/tmp", files[0]); + Assert.Equal ("/home", files[1]); + } + + [Fact] + public void ConsumerGlobalOption_NonRepeatable_LastWins () + { + GlobalOptionDescriptor port = new ("port", "p", "Port number", IsFlag: false, Repeatable: false); + ArgParser parser = CreateParser (port); + ArgParser.ParseResult result = parser.Parse (["serve", "--port", "8080", "--port", "9090"]); + + IReadOnlyList values = result.Options!.GetExtensionList ("port"); + Assert.Single (values); + Assert.Equal ("9090", values[0]); + } + + [Fact] + public void ConsumerGlobalOption_EqualsValueSyntax () + { + GlobalOptionDescriptor allowFile = new ("allow-file", null, "Permit file", IsFlag: false, Repeatable: true); + ArgParser parser = CreateParser (allowFile); + ArgParser.ParseResult result = parser.Parse (["edit", "--allow-file=/tmp/foo"]); + + IReadOnlyList files = result.Options!.GetExtensionList ("allow-file"); + Assert.Single (files); + Assert.Equal ("/tmp/foo", files[0]); + } + + [Fact] + public void Initial_ExceedsMaxChars_Fails () + { + ArgParser parser = new ([], maxInitialChars: 10); + ArgParser.ParseResult result = parser.Parse (["select", "--initial", "12345678901"]); + + Assert.False (result.Success); + Assert.Contains ("character limit", result.Error); + } + + [Fact] + public void TryParseTimeout_Seconds () + { + Assert.True (ArgParser.TryParseTimeout ("30s", out TimeSpan t)); + Assert.Equal (TimeSpan.FromSeconds (30), t); + } + + [Fact] + public void TryParseTimeout_Milliseconds () + { + Assert.True (ArgParser.TryParseTimeout ("500ms", out TimeSpan t)); + Assert.Equal (TimeSpan.FromMilliseconds (500), t); + } + + [Fact] + public void TryParseTimeout_Minutes () + { + Assert.True (ArgParser.TryParseTimeout ("2m", out TimeSpan t)); + Assert.Equal (TimeSpan.FromMinutes (2), t); + } + + [Fact] + public void TryParseTimeout_Hours () + { + Assert.True (ArgParser.TryParseTimeout ("1h", out TimeSpan t)); + Assert.Equal (TimeSpan.FromHours (1), t); + } + + [Fact] + public void TryParseTimeout_Invalid_ReturnsFalse () + { + Assert.False (ArgParser.TryParseTimeout ("abc", out _)); + Assert.False (ArgParser.TryParseTimeout ("", out _)); + Assert.False (ArgParser.TryParseTimeout ("0s", out _)); + Assert.False (ArgParser.TryParseTimeout ("-5s", out _)); + } +} diff --git a/tests/Terminal.Gui.Cli.Tests/CliHostTests.cs b/tests/Terminal.Gui.Cli.Tests/CliHostTests.cs new file mode 100644 index 0000000..a6e6bd0 --- /dev/null +++ b/tests/Terminal.Gui.Cli.Tests/CliHostTests.cs @@ -0,0 +1,156 @@ +using Xunit; + +namespace Terminal.Gui.Cli.Tests; + +public class CliHostTests +{ + [Fact] + public async Task RunAsync_NoArgs_ReturnsOkAndWritesHelp () + { + using StringWriter stdout = new (); + using StringWriter stderr = new (); + + CliHost host = new (o => o.ApplicationName = "testapp"); + + int exitCode = await host.RunAsync ([], TestContext.Current.CancellationToken, stdout, stderr); + + Assert.Equal (ExitCodes.Ok, exitCode); + Assert.Contains ("Commands:", stdout.ToString ()); + } + + [Fact] + public async Task RunAsync_Version_WritesVersionString () + { + using StringWriter stdout = new (); + using StringWriter stderr = new (); + + CliHost host = new (o => + { + o.ApplicationName = "testapp"; + o.Version = "1.2.3"; + }); + + int exitCode = await host.RunAsync (["--version"], TestContext.Current.CancellationToken, stdout, stderr); + + Assert.Equal (ExitCodes.Ok, exitCode); + Assert.Contains ("testapp 1.2.3", stdout.ToString ()); + } + + [Fact] + public async Task RunAsync_UnknownCommand_ReturnsUsageError () + { + using StringWriter stdout = new (); + using StringWriter stderr = new (); + + CliHost host = new (o => o.ApplicationName = "testapp"); + + int exitCode = await host.RunAsync (["nope"], TestContext.Current.CancellationToken, stdout, stderr); + + Assert.Equal (ExitCodes.UsageError, exitCode); + Assert.Contains ("unknown command", stderr.ToString ()); + } + + [Fact] + public async Task RunAsync_OpenCli_WritesJson () + { + using StringWriter stdout = new (); + using StringWriter stderr = new (); + + CliHost host = new (o => + { + o.ApplicationName = "testapp"; + o.Version = "0.1.0"; + }); + host.Registry.Register (new StubCommand ("demo", ["demo", "d"])); + + int exitCode = await host.RunAsync (["--opencli"], TestContext.Current.CancellationToken, stdout, stderr); + + Assert.Equal (ExitCodes.Ok, exitCode); + string output = stdout.ToString (); + Assert.Contains ("\"opencli\":\"0.1\"", output); + Assert.Contains ("\"demo\"", output); + } + + [Fact] + public async Task RunAsync_CommandHelp_ReturnsOk () + { + using StringWriter stdout = new (); + using StringWriter stderr = new (); + + CliHost host = new (o => o.ApplicationName = "testapp"); + host.Registry.Register (new StubCommand ("demo", ["demo"])); + + int exitCode = await host.RunAsync (["demo", "--help"], TestContext.Current.CancellationToken, stdout, stderr); + + Assert.Equal (ExitCodes.Ok, exitCode); + Assert.Contains ("Stub command: demo", stdout.ToString ()); + } + + [Fact] + public async Task RunAsync_InvalidOption_ReturnsUsageError () + { + using StringWriter stdout = new (); + using StringWriter stderr = new (); + + CliHost host = new (o => o.ApplicationName = "testapp"); + host.Registry.Register (new StubCommand ("demo", ["demo"])); + + int exitCode = await host.RunAsync (["demo", "--unknown", "val"], TestContext.Current.CancellationToken, stdout, stderr); + + Assert.Equal (ExitCodes.UsageError, exitCode); + Assert.Contains ("unknown option", stderr.ToString ()); + } + + [Fact] + public async Task RunAsync_ConsumerGlobalOptions_FlowToExtensions () + { + using StringWriter stdout = new (); + using StringWriter stderr = new (); + + ExtensionCapturingCommand cmd = new (); + + CliHost host = new (o => + { + o.ApplicationName = "testapp"; + o.GlobalOptions.Add (new ("allow-file", null, "Permit file access", IsFlag: false, Repeatable: true)); + o.GlobalOptions.Add (new ("no-browse", null, "Disable browsing", IsFlag: true)); + }); + host.Registry.Register (cmd); + + // This test exercises parsing only — TUI dispatch would hang without a terminal. + // We verify the parse succeeds and options flow correctly by checking that the + // command receives them (command captures options in RunAsync before throwing). + int exitCode = await host.RunAsync ( + ["capture", "--allow-file", "/tmp", "--no-browse", "--json"], + TestContext.Current.CancellationToken, stdout, stderr); + + // The command will return Ok and we can verify from its captured options + Assert.Equal (ExitCodes.Ok, exitCode); + Assert.True (cmd.CapturedOptions!.HasExtension ("no-browse")); + Assert.Equal ("/tmp", cmd.CapturedOptions!.GetExtensionList ("allow-file")[0]); + } +} + +/// Command that captures its options for test verification (no TUI). +internal sealed class ExtensionCapturingCommand : ICliCommand +{ + public string PrimaryAlias => "capture"; + public IReadOnlyList Aliases => ["capture"]; + public string Description => "Captures options for testing"; + public CommandKind Kind => CommandKind.Input; + public Type ResultType => typeof (string); + public IReadOnlyList Options => []; + + public CommandRunOptions? CapturedOptions { get; private set; } + + public Task RunAsync ( + Terminal.Gui.App.IApplication app, + string? initial, + CommandRunOptions options, + CancellationToken cancellationToken) + { + CapturedOptions = options; + + return Task.FromResult (new CommandResult (CommandStatus.Ok, "captured", null, null)); + } +} diff --git a/tests/Terminal.Gui.Cli.Tests/CommandRegistryTests.cs b/tests/Terminal.Gui.Cli.Tests/CommandRegistryTests.cs new file mode 100644 index 0000000..fceece5 --- /dev/null +++ b/tests/Terminal.Gui.Cli.Tests/CommandRegistryTests.cs @@ -0,0 +1,83 @@ +using Xunit; + +namespace Terminal.Gui.Cli.Tests; + +public class CommandRegistryTests +{ + [Fact] + public void Register_AddsCommand_ResolvableByAlias () + { + CommandRegistry registry = new (); + StubCommand cmd = new ("test", ["test", "t"]); + + registry.Register (cmd); + + Assert.True (registry.TryResolve ("test", out ICliCommand? resolved)); + Assert.Same (cmd, resolved); + } + + [Fact] + public void TryResolve_CaseInsensitive () + { + CommandRegistry registry = new (); + StubCommand cmd = new ("select", ["select", "sel"]); + registry.Register (cmd); + + Assert.True (registry.TryResolve ("SELECT", out ICliCommand? resolved)); + Assert.Same (cmd, resolved); + } + + [Fact] + public void TryResolve_ShortAlias () + { + CommandRegistry registry = new (); + StubCommand cmd = new ("select", ["select", "sel"]); + registry.Register (cmd); + + Assert.True (registry.TryResolve ("sel", out ICliCommand? resolved)); + Assert.Same (cmd, resolved); + } + + [Fact] + public void TryResolve_UnknownAlias_ReturnsFalse () + { + CommandRegistry registry = new (); + + Assert.False (registry.TryResolve ("nope", out _)); + } + + [Fact] + public void Register_DuplicateAlias_Throws () + { + CommandRegistry registry = new (); + StubCommand cmd1 = new ("a", ["a", "shared"]); + StubCommand cmd2 = new ("b", ["b", "shared"]); + registry.Register (cmd1); + + Assert.Throws (() => registry.Register (cmd2)); + } + + [Fact] + public void All_ReturnsRegisteredCommands () + { + CommandRegistry registry = new (); + StubCommand cmd1 = new ("a", ["a"]); + StubCommand cmd2 = new ("b", ["b"]); + registry.Register (cmd1); + registry.Register (cmd2); + + Assert.Equal (2, registry.All.Count); + Assert.Contains (cmd1, registry.All); + Assert.Contains (cmd2, registry.All); + } + + [Fact] + public void Register_PrimaryAliasNotInAliases_Throws () + { + CommandRegistry registry = new (); + StubCommand cmd = new ("primary", ["other"]); + + InvalidOperationException ex = Assert.Throws (() => registry.Register (cmd)); + Assert.Contains ("PrimaryAlias", ex.Message); + } +} diff --git a/tests/Terminal.Gui.Cli.Tests/CommandRunOptionsTests.cs b/tests/Terminal.Gui.Cli.Tests/CommandRunOptionsTests.cs new file mode 100644 index 0000000..0a7e071 --- /dev/null +++ b/tests/Terminal.Gui.Cli.Tests/CommandRunOptionsTests.cs @@ -0,0 +1,100 @@ +using Xunit; + +namespace Terminal.Gui.Cli.Tests; + +public class CommandRunOptionsTests +{ + [Fact] + public void GetExtension_ReturnsLastValue () + { + CommandRunOptions options = new () + { + Extensions = new Dictionary> + { + ["port"] = ["8080", "9090"] + } + }; + + int? port = options.GetExtension ("port", int.Parse, 3000); + + Assert.Equal (9090, port); + } + + [Fact] + public void GetExtension_MissingKey_ReturnsDefault () + { + CommandRunOptions options = new (); + + int? port = options.GetExtension ("port", int.Parse, 3000); + + Assert.Equal (3000, port); + } + + [Fact] + public void GetExtensionList_ReturnsAllValues () + { + CommandRunOptions options = new () + { + Extensions = new Dictionary> + { + ["allow-file"] = ["/tmp", "/home"] + } + }; + + IReadOnlyList files = options.GetExtensionList ("allow-file"); + + Assert.Equal (2, files.Count); + Assert.Equal ("/tmp", files[0]); + Assert.Equal ("/home", files[1]); + } + + [Fact] + public void GetExtensionList_MissingKey_ReturnsEmpty () + { + CommandRunOptions options = new (); + + IReadOnlyList files = options.GetExtensionList ("nope"); + + Assert.Empty (files); + } + + [Fact] + public void HasExtension_ReturnsTrue_WhenPresent () + { + CommandRunOptions options = new () + { + Extensions = new Dictionary> + { + ["no-browse"] = [""] + } + }; + + Assert.True (options.HasExtension ("no-browse")); + } + + [Fact] + public void HasExtension_ReturnsFalse_WhenAbsent () + { + CommandRunOptions options = new (); + + Assert.False (options.HasExtension ("no-browse")); + } + + [Fact] + public void Defaults_AreCorrect () + { + CommandRunOptions options = new (); + + Assert.Null (options.Initial); + Assert.Null (options.Title); + Assert.False (options.JsonOutput); + Assert.Null (options.Timeout); + Assert.False (options.Fullscreen); + Assert.False (options.Cat); + Assert.Null (options.OutputPath); + Assert.Null (options.Rows); + Assert.Empty (options.Arguments); + Assert.Empty (options.CommandOptions); + Assert.Empty (options.Extensions); + } +} diff --git a/tests/Terminal.Gui.Cli.Tests/ExitCodesTests.cs b/tests/Terminal.Gui.Cli.Tests/ExitCodesTests.cs new file mode 100644 index 0000000..df4b78e --- /dev/null +++ b/tests/Terminal.Gui.Cli.Tests/ExitCodesTests.cs @@ -0,0 +1,62 @@ +using Xunit; + +namespace Terminal.Gui.Cli.Tests; + +public class ExitCodesTests +{ + [Fact] + public void FromResult_Ok_Returns0 () + { + CommandResult result = new (CommandStatus.Ok, "value", null, null); + + Assert.Equal (0, ExitCodes.FromResult (result)); + } + + [Fact] + public void FromResult_Cancelled_Returns130 () + { + CommandResult result = new (CommandStatus.Cancelled, null, null, null); + + Assert.Equal (130, ExitCodes.FromResult (result)); + } + + [Fact] + public void FromResult_NoResult_Returns1 () + { + CommandResult result = new (CommandStatus.NoResult, null, null, null); + + Assert.Equal (1, ExitCodes.FromResult (result)); + } + + [Fact] + public void FromResult_Error_Validation_Returns65 () + { + CommandResult result = new (CommandStatus.Error, null, "validation", "bad input"); + + Assert.Equal (65, ExitCodes.FromResult (result)); + } + + [Fact] + public void FromResult_Error_InputTooLarge_Returns65 () + { + CommandResult result = new (CommandStatus.Error, null, "input-too-large", "too big"); + + Assert.Equal (65, ExitCodes.FromResult (result)); + } + + [Fact] + public void FromResult_Error_Io_Returns74 () + { + CommandResult result = new (CommandStatus.Error, null, "io", "disk full"); + + Assert.Equal (74, ExitCodes.FromResult (result)); + } + + [Fact] + public void FromResult_Error_Unknown_Returns2 () + { + CommandResult result = new (CommandStatus.Error, null, "something-else", "oops"); + + Assert.Equal (2, ExitCodes.FromResult (result)); + } +} diff --git a/tests/Terminal.Gui.Cli.Tests/JsonEnvelopeTests.cs b/tests/Terminal.Gui.Cli.Tests/JsonEnvelopeTests.cs new file mode 100644 index 0000000..976b807 --- /dev/null +++ b/tests/Terminal.Gui.Cli.Tests/JsonEnvelopeTests.cs @@ -0,0 +1,64 @@ +using System.Text.Json; +using Xunit; + +namespace Terminal.Gui.Cli.Tests; + +public class JsonEnvelopeTests +{ + [Fact] + public void Ok_ProducesCorrectJson () + { + JsonEnvelope envelope = JsonEnvelope.Ok ("hello"); + string json = envelope.ToJson (); + + using JsonDocument doc = JsonDocument.Parse (json); + JsonElement root = doc.RootElement; + + Assert.Equal (1, root.GetProperty ("schemaVersion").GetInt32 ()); + Assert.Equal ("ok", root.GetProperty ("status").GetString ()); + Assert.Equal ("hello", root.GetProperty ("value").GetString ()); + Assert.False (root.TryGetProperty ("code", out _)); + Assert.False (root.TryGetProperty ("message", out _)); + } + + [Fact] + public void Cancelled_OmitsValueAndCode () + { + JsonEnvelope envelope = JsonEnvelope.Cancelled (); + string json = envelope.ToJson (); + + using JsonDocument doc = JsonDocument.Parse (json); + JsonElement root = doc.RootElement; + + Assert.Equal ("cancelled", root.GetProperty ("status").GetString ()); + Assert.False (root.TryGetProperty ("value", out _)); + Assert.False (root.TryGetProperty ("code", out _)); + } + + [Fact] + public void Error_IncludesCodeAndMessage () + { + JsonEnvelope envelope = JsonEnvelope.Error ("io", "disk full"); + string json = envelope.ToJson (); + + using JsonDocument doc = JsonDocument.Parse (json); + JsonElement root = doc.RootElement; + + Assert.Equal ("error", root.GetProperty ("status").GetString ()); + Assert.Equal ("io", root.GetProperty ("code").GetString ()); + Assert.Equal ("disk full", root.GetProperty ("message").GetString ()); + } + + [Fact] + public void NoResult_HasCorrectStatus () + { + JsonEnvelope envelope = JsonEnvelope.NoResult (); + string json = envelope.ToJson (); + + using JsonDocument doc = JsonDocument.Parse (json); + JsonElement root = doc.RootElement; + + Assert.Equal ("no-result", root.GetProperty ("status").GetString ()); + Assert.False (root.TryGetProperty ("value", out _)); + } +} diff --git a/tests/Terminal.Gui.Cli.Tests/StubCommand.cs b/tests/Terminal.Gui.Cli.Tests/StubCommand.cs new file mode 100644 index 0000000..707de23 --- /dev/null +++ b/tests/Terminal.Gui.Cli.Tests/StubCommand.cs @@ -0,0 +1,23 @@ +using Terminal.Gui.App; + +namespace Terminal.Gui.Cli.Tests; + +/// Minimal ICliCommand implementation for testing. +internal sealed class StubCommand (string primaryAlias, IReadOnlyList aliases) : ICliCommand +{ + public string PrimaryAlias => primaryAlias; + public IReadOnlyList Aliases => aliases; + public string Description => $"Stub command: {primaryAlias}"; + public CommandKind Kind => CommandKind.Input; + public Type ResultType => typeof (string); + public IReadOnlyList Options => []; + + public Task RunAsync ( + IApplication app, + string? initial, + CommandRunOptions options, + CancellationToken cancellationToken) + { + return Task.FromResult (new CommandResult (CommandStatus.Ok, initial ?? "default", null, null)); + } +} diff --git a/tests/Terminal.Gui.Cli.Tests/Terminal.Gui.Cli.Tests.csproj b/tests/Terminal.Gui.Cli.Tests/Terminal.Gui.Cli.Tests.csproj new file mode 100644 index 0000000..72fe0d3 --- /dev/null +++ b/tests/Terminal.Gui.Cli.Tests/Terminal.Gui.Cli.Tests.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + Exe + enable + enable + Terminal.Gui.Cli.Tests + + + + + + + + + + + diff --git a/tests/Terminal.Gui.Cli.Tests/TerminalEscapeSanitizerTests.cs b/tests/Terminal.Gui.Cli.Tests/TerminalEscapeSanitizerTests.cs new file mode 100644 index 0000000..c0b8638 --- /dev/null +++ b/tests/Terminal.Gui.Cli.Tests/TerminalEscapeSanitizerTests.cs @@ -0,0 +1,104 @@ +using Xunit; + +namespace Terminal.Gui.Cli.Tests; + +public class TerminalEscapeSanitizerTests +{ + [Fact] + public void Sanitize_Null_ReturnsNull () + { + Assert.Null (TerminalEscapeSanitizer.Sanitize (null)); + } + + [Fact] + public void Sanitize_Empty_ReturnsEmpty () + { + Assert.Equal (string.Empty, TerminalEscapeSanitizer.Sanitize (string.Empty)); + } + + [Fact] + public void Sanitize_CleanInput_Unchanged () + { + Assert.Equal ("hello world", TerminalEscapeSanitizer.Sanitize ("hello world")); + } + + [Fact] + public void Sanitize_StripsBareEsc () + { + Assert.Equal ("ab", TerminalEscapeSanitizer.Sanitize ("a\u001b" + "b")); + } + + [Fact] + public void Sanitize_StripsBel () + { + Assert.Equal ("ab", TerminalEscapeSanitizer.Sanitize ("a\u0007" + "b")); + } + + [Fact] + public void Sanitize_Strips8BitCsi () + { + Assert.Equal ("ab", TerminalEscapeSanitizer.Sanitize ("a\u009b" + "b")); + } + + [Fact] + public void Sanitize_Strips8BitOsc () + { + Assert.Equal ("ab", TerminalEscapeSanitizer.Sanitize ("a\u009d" + "b")); + } + + [Fact] + public void Sanitize_StripsC1SevenBitPair () + { + // ESC followed by @ through _ is a C1 pair — both bytes stripped + Assert.Equal ("ab", TerminalEscapeSanitizer.Sanitize ("a\u001b@b")); + Assert.Equal ("ab", TerminalEscapeSanitizer.Sanitize ("a\u001b_b")); + } + + [Fact] + public void SanitizeRenderedOutput_PreservesSgr () + { + string input = "\u001b[31mred\u001b[0m"; + + Assert.Equal (input, TerminalEscapeSanitizer.SanitizeRenderedOutput (input)); + } + + [Fact] + public void SanitizeRenderedOutput_StripsNonHyperlinkOsc () + { + // OSC 0 (set title) — should be stripped + string input = "before\u001b]0;evil title\u0007after"; + string result = TerminalEscapeSanitizer.SanitizeRenderedOutput (input); + + Assert.Equal ("beforeafter", result); + } + + [Fact] + public void SanitizeRenderedOutput_PreservesOsc8Hyperlink () + { + // OSC 8 hyperlink — should be preserved + string input = "\u001b]8;;https://example.com\u0007link\u001b]8;;\u0007"; + string result = TerminalEscapeSanitizer.SanitizeRenderedOutput (input); + + Assert.Equal (input, result); + } + + [Fact] + public void SanitizeRenderedOutput_StripsNonSgrCsi () + { + // CSI 6n (device status report / cursor position query) — should be stripped + string input = "before\u001b[6nafter"; + string result = TerminalEscapeSanitizer.SanitizeRenderedOutput (input); + + Assert.Equal ("beforeafter", result); + } + + [Fact] + public void SanitizeRenderedOutput_StripsEraseCsi () + { + // CSI 2J (erase display) — should be stripped + string input = "before\u001b[2Jafter"; + string result = TerminalEscapeSanitizer.SanitizeRenderedOutput (input); + + Assert.Equal ("beforeafter", result); + } +} diff --git a/tests/Terminal.Gui.Cli.Tests/TypeNamesTests.cs b/tests/Terminal.Gui.Cli.Tests/TypeNamesTests.cs new file mode 100644 index 0000000..aaaf564 --- /dev/null +++ b/tests/Terminal.Gui.Cli.Tests/TypeNamesTests.cs @@ -0,0 +1,38 @@ +using System.Text.Json.Nodes; +using Xunit; + +namespace Terminal.Gui.Cli.Tests; + +public class TypeNamesTests +{ + [Theory] + [InlineData (typeof (string), "string")] + [InlineData (typeof (int), "int")] + [InlineData (typeof (long), "int")] + [InlineData (typeof (short), "int")] + [InlineData (typeof (decimal), "decimal")] + [InlineData (typeof (double), "decimal")] + [InlineData (typeof (float), "decimal")] + [InlineData (typeof (bool), "bool")] + [InlineData (typeof (DateTime), "date")] + [InlineData (typeof (DateOnly), "date")] + [InlineData (typeof (TimeOnly), "time")] + [InlineData (typeof (TimeSpan), "duration")] + [InlineData (typeof (JsonArray), "array")] + [InlineData (typeof (JsonObject), "object")] + [InlineData (typeof (JsonNode), "json")] + [InlineData (typeof (void), "none")] + public void WireName_MapsCorrectly (Type type, string expected) + { + Assert.Equal (expected, TypeNames.WireName (type)); + } + + [Theory] + [InlineData (typeof (int?), "int")] + [InlineData (typeof (bool?), "bool")] + [InlineData (typeof (DateTime?), "date")] + public void WireName_Nullable_UnwrapsCorrectly (Type type, string expected) + { + Assert.Equal (expected, TypeNames.WireName (type)); + } +}