39 Commits
1.3.1 ... 1.6.0

Author SHA1 Message Date
Matt Nadareski
7831da365c Bump version 2026-11-11 09:20:10 +01:00
Matt Nadareski
c5ec963fd1 Add .NET 11 support 2026-11-11 09:18:03 +01:00
Matt Nadareski
4efc3becf5 Add .NET 11 support 2026-11-11 09:15:50 +01:00
Matt Nadareski
88c2c25a71 Bump version 2026-07-10 09:30:28 -04:00
Matt Nadareski
71ef11ac49 Update copyright dates 2026-07-10 09:25:25 -04:00
Matt Nadareski
2753759b33 Update test packages 2026-07-10 09:24:13 -04:00
Matt Nadareski
09f08a9031 Clean up formatting to personal taste 2026-07-10 09:04:55 -04:00
Matt Nadareski
8695e519c6 Add new manpage reference implementation to README 2026-07-01 13:54:59 -04:00
gmipf
754aa6bc32 Add man page generation from the command-line model (#2)
* Add man page generation from the command-line model

Add CommandSet.OutputManPage (string and file overloads), a ManPageInfo
type for document-level metadata, a reference ManPage feature invoked as a
man command, and UserInput.FormatManPage which reuses FormatFlags so the
page cannot drift from --help. Output uses only portable man(7) macros and
validates warning-free under groff and mandoc -T lint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Address review feedback on man page generation

Rename the man page API to the single-word "Manpage" spelling throughout
(Manpage feature, ManpageInfo, CommandSet.OutputManpage,
UserInput.FormatManpage) for consistency with the "manpage" convention.

- Drop the redundant null check in OutputManpage; info is non-nullable
  under the enabled nullable context.
- Move the tests that exercise CommandSet.OutputManpage into
  CommandSetTests as a "Manpage Output" region, and relocate the Manpage
  feature test into a path-synced Features subfolder
  (SabreTools.CommandLine.Test.Features). Private test helpers now sit at
  the bottom of their classes.
- Fix the Manpage feature doc example to use the actual "man" command.

Builds clean across all target frameworks; the 235 tests pass and the
generated page still validates warning-free under groff -man -ww and
mandoc -T lint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 13:53:34 -04:00
Matt Nadareski
1da60f1091 Remove CONTRIBUTING due to org-level version 2026-05-16 15:01:49 -04:00
Matt Nadareski
5fd4b8b0a5 Update CONTRIBUTING to be more accurate 2026-05-15 13:05:20 -04:00
Matt Nadareski
2bca1ff744 Add CONTRIBUTING document 2026-05-15 10:59:28 -04:00
Matt Nadareski
8be595bf56 Update gitignore for new VSCode feature 2026-05-15 10:34:14 -04:00
Matt Nadareski
47d067df1c Add editorconfig, fix issues 2026-01-25 17:14:38 -05:00
Matt Nadareski
90bfcc8fe2 Format GHA definitions 2025-11-17 08:36:23 -05:00
Matt Nadareski
be081c701d Bump version 2025-11-12 19:49:06 -05:00
Matt Nadareski
66b0e27d4e Add support for .NET 10 2025-11-11 16:43:21 -05:00
Matt Nadareski
573fe2e160 Update rolling tag 2025-10-26 20:22:42 -04:00
Matt Nadareski
6ad9fa4521 Fix split method in custom help 2025-10-07 12:37:54 -04:00
Matt Nadareski
a261f428f2 Add and allow custom help text 2025-10-07 12:36:00 -04:00
Matt Nadareski
5011331164 Clarify the case where default also has real flags 2025-10-07 12:27:51 -04:00
Matt Nadareski
e257c30892 Clarify help printing with default features 2025-10-07 12:14:18 -04:00
Matt Nadareski
029cfe6dc6 Add optional default feature to CommandSet 2025-10-07 12:13:11 -04:00
Matt Nadareski
f38b8734b5 Simplify the method name 2025-10-07 12:07:41 -04:00
Matt Nadareski
df54b92031 Add AddChildrenFrom method to CommandSet 2025-10-07 12:03:00 -04:00
Matt Nadareski
39acb90dbc Use All help by default instead of top-level only 2025-10-06 08:50:21 -04:00
Matt Nadareski
43083d35e1 Bump version 2025-10-06 07:21:36 -04:00
Matt Nadareski
fddbfa963d Directly return result of execution 2025-10-06 07:19:41 -04:00
Matt Nadareski
94ac68e5a0 Handle default help functionality 2025-10-06 07:19:23 -04:00
Matt Nadareski
0c3373e9f0 Basically all cases should output help 2025-10-05 23:26:13 -04:00
Matt Nadareski
f122bf6b6d Add tests for default parsing 2025-10-05 23:14:02 -04:00
Matt Nadareski
81b300bae6 Add default implementation of parsing in CommandSet 2025-10-05 23:00:20 -04:00
Matt Nadareski
a0576df3ee Add single-string variants to CommandSet construction 2025-10-05 22:41:18 -04:00
Matt Nadareski
e26a222755 Allow nested searches for feature values 2025-10-05 22:32:16 -04:00
Matt Nadareski
608cdae1d8 Add Get/TryGet for Feature in UserInput 2025-10-05 22:24:25 -04:00
Matt Nadareski
0bb996153a Add Get/TryGet for Feature in CommandSet 2025-10-05 22:18:48 -04:00
Matt Nadareski
1ecce85a13 Add command set Get and TryGet tests 2025-10-05 21:52:29 -04:00
Matt Nadareski
644527dbfa Add Get and TryGet to CommandSet 2025-10-05 21:48:12 -04:00
Matt Nadareski
1bd158eba0 Allow controlling detailed help 2025-10-05 20:16:47 -04:00
30 changed files with 2931 additions and 122 deletions

167
.editorconfig Normal file
View File

@@ -0,0 +1,167 @@
# top-most EditorConfig file
root = true
# C# files
[*.cs]
# Indentation and spacing
charset = utf-8
indent_size = 4
indent_style = space
tab_width = 4
trim_trailing_whitespace = true
# New line preferences
end_of_line = lf
insert_final_newline = true
max_line_length = unset
# using directive preferences
csharp_using_directive_placement = outside_namespace
dotnet_diagnostic.IDE0005.severity = error
# Code-block preferences
csharp_style_namespace_declarations = block_scoped
csharp_style_prefer_method_group_conversion = true
csharp_style_prefer_top_level_statements = false
# Expression-level preferences
csharp_prefer_simple_default_expression = true
csharp_style_inlined_variable_declaration = true
csharp_style_unused_value_assignment_preference = discard_variable
csharp_style_unused_value_expression_statement_preference = discard_variable
dotnet_diagnostic.IDE0001.severity = warning
dotnet_diagnostic.IDE0002.severity = warning
dotnet_diagnostic.IDE0004.severity = warning
dotnet_diagnostic.IDE0010.severity = error
dotnet_diagnostic.IDE0051.severity = warning
dotnet_diagnostic.IDE0052.severity = warning
dotnet_diagnostic.IDE0072.severity = warning
dotnet_diagnostic.IDE0080.severity = warning
dotnet_diagnostic.IDE0100.severity = error
dotnet_diagnostic.IDE0110.severity = error
dotnet_diagnostic.IDE0120.severity = warning
dotnet_diagnostic.IDE0121.severity = warning
dotnet_diagnostic.IDE0240.severity = error
dotnet_diagnostic.IDE0241.severity = error
dotnet_style_coalesce_expression = true
dotnet_style_namespace_match_folder = false
dotnet_style_null_propagation = true
dotnet_style_prefer_auto_properties = true
dotnet_style_prefer_collection_expression = when_types_loosely_match
dotnet_style_prefer_is_null_check_over_reference_equality_method = true
dotnet_style_prefer_compound_assignment = true
csharp_style_prefer_simple_property_accessors = true
dotnet_style_prefer_simplified_interpolation = true
dotnet_style_prefer_simplified_boolean_expressions = true
csharp_style_prefer_unbound_generic_type_in_nameof = true
# Field preferences
dotnet_diagnostic.IDE0044.severity = warning
dotnet_style_readonly_field = true
# Language keyword vs. framework types preferences
dotnet_diagnostic.IDE0049.severity = error
dotnet_style_predefined_type_for_locals_parameters_members = true
dotnet_style_predefined_type_for_member_access = true
# Modifier preferences
csharp_prefer_static_local_function = true
csharp_style_prefer_readonly_struct = true
dotnet_diagnostic.IDE0036.severity = warning
dotnet_diagnostic.IDE0040.severity = error
dotnet_diagnostic.IDE0380.severity = error
dotnet_style_require_accessibility_modifiers = always
# New-line preferences
dotnet_diagnostic.IDE2000.severity = warning
dotnet_diagnostic.IDE2002.severity = warning
dotnet_diagnostic.IDE2003.severity = warning
dotnet_diagnostic.IDE2004.severity = warning
dotnet_diagnostic.IDE2005.severity = warning
dotnet_diagnostic.IDE2006.severity = warning
csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = false
csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = false
csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = false
csharp_style_allow_blank_lines_between_consecutive_braces_experimental = false
dotnet_style_allow_multiple_blank_lines_experimental = false
dotnet_style_allow_statement_immediately_after_block_experimental = false
# Null-checking preferences
csharp_style_conditional_delegate_call = true
# Parameter preferences
dotnet_code_quality_unused_parameters = all
dotnet_diagnostic.IDE0280.severity = error
# Parentheses preferences
dotnet_diagnostic.IDE0047.severity = warning
dotnet_diagnostic.IDE0048.severity = warning
dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity
dotnet_style_parentheses_in_other_binary_operators = always_for_clarity
dotnet_style_parentheses_in_other_operators = always_for_clarity
dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity
# Pattern-matching preferences
dotnet_diagnostic.IDE0019.severity = warning
dotnet_diagnostic.IDE0020.severity = warning
dotnet_diagnostic.IDE0038.severity = warning
dotnet_diagnostic.IDE0066.severity = none
dotnet_diagnostic.IDE0083.severity = warning
dotnet_diagnostic.IDE0260.severity = warning
csharp_style_pattern_matching_over_as_with_null_check = true
csharp_style_pattern_matching_over_is_with_cast_check = true
csharp_style_prefer_not_pattern = true
csharp_style_prefer_pattern_matching = true
# this. and Me. preferences
dotnet_style_qualification_for_event = false
dotnet_style_qualification_for_field = false
dotnet_style_qualification_for_method = false
dotnet_style_qualification_for_property = false
# var preferences
csharp_style_var_for_built_in_types = false
csharp_style_var_when_type_is_apparent = true
# .NET formatting options
dotnet_separate_import_directive_groups = false
dotnet_sort_system_directives_first = true
# C# formatting options
csharp_indent_block_contents = true
csharp_indent_braces = false
csharp_indent_case_contents = true
csharp_indent_case_contents_when_block = false
csharp_indent_labels = one_less_than_current
csharp_indent_switch_labels = true
csharp_new_line_before_catch = true
csharp_new_line_before_else = true
csharp_new_line_before_finally = true
csharp_new_line_before_members_in_anonymous_types = true
csharp_new_line_before_members_in_object_initializers = true
csharp_new_line_before_open_brace = all
csharp_new_line_between_query_expression_clauses = true
csharp_preserve_single_line_blocks = true
csharp_preserve_single_line_statements = true
csharp_space_after_cast = false
csharp_space_after_colon_in_inheritance_clause = true
csharp_space_after_comma = true
csharp_space_after_dot = false
csharp_space_after_keywords_in_control_flow_statements = true
csharp_space_after_semicolon_in_for_statement = true
csharp_space_around_binary_operators = before_and_after
csharp_space_around_declaration_statements = false
csharp_space_before_colon_in_inheritance_clause = true
csharp_space_before_comma = false
csharp_space_before_dot = false
csharp_space_before_open_square_brackets = false
csharp_space_before_semicolon_in_for_statement = false
csharp_space_between_empty_square_brackets = false
csharp_space_between_method_call_empty_parameter_list_parentheses = false
csharp_space_between_method_call_name_and_opening_parenthesis = false
csharp_space_between_method_call_parameter_list_parentheses = false
csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
csharp_space_between_method_declaration_parameter_list_parentheses = false
csharp_space_between_parentheses = false
csharp_space_between_square_brackets = false

View File

@@ -1,40 +1,49 @@
name: Build and Test
on:
push:
branches: [ "main" ]
push:
branches: ["main"]
jobs:
build:
runs-on: ubuntu-latest
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
6.0.x
8.0.x
9.0.x
- name: Run tests
run: dotnet test
- name: Run publish script
run: ./publish-nix.sh
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Upload to rolling
uses: ncipollo/release-action@v1.14.0
with:
allowUpdates: True
artifacts: "*.nupkg,*.snupkg"
body: 'Last built commit: ${{ github.sha }}'
name: 'Rolling Release'
prerelease: True
replacesArtifacts: True
tag: "rolling"
updateOnlyUnreleased: True
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: |
8.0.x
9.0.x
10.0.x
11.0.x
- name: Run tests
run: dotnet test
- name: Run publish script
run: ./publish-nix.sh
- name: Update rolling tag
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -f rolling
git push origin :refs/tags/rolling || true
git push origin rolling --force
- name: Upload to rolling
uses: ncipollo/release-action@v1.20.0
with:
allowUpdates: True
artifacts: "*.nupkg,*.snupkg"
body: "Last built commit: ${{ github.sha }}"
name: "Rolling Release"
prerelease: True
replacesArtifacts: True
tag: "rolling"
updateOnlyUnreleased: True

View File

@@ -3,21 +3,22 @@ name: Build PR
on: [pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
6.0.x
8.0.x
9.0.x
- name: Build
run: dotnet build
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Run tests
run: dotnet test
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: |
8.0.x
9.0.x
10.0.x
11.0.x
- name: Build
run: dotnet build
- name: Run tests
run: dotnet test

329
.gitignore vendored
View File

@@ -1,22 +1,18 @@
*.swp
*.*~
project.lock.json
.DS_Store
*.pyc
nupkg/
# Visual Studio Code
.vscode
# Rider
.idea
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# User-specific files
*.lscache
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Build results
[Dd]ebug/
[Dd]ebugPublic/
@@ -24,15 +20,312 @@ nupkg/
[Rr]eleases/
x64/
x86/
build/
bld/
[Bb]in/
[Oo]bj/
[Oo]ut/
msbuild.log
msbuild.err
msbuild.wrn
[Ll]og/
# Visual Studio 2015
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUNIT
*.VisualState.xml
TestResult.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
**/Properties/launchSettings.json
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_i.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# JustCode is a .NET coding add-in
.JustCode
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# JetBrains Rider
.idea/
*.sln.iml
# CodeRush
.cr/
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/

24
.vscode/tasks.json vendored Executable file
View File

@@ -0,0 +1,24 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
"version": "2.0.0",
"tasks": [
{
"label": "build",
"command": "dotnet",
"type": "shell",
"args": [
"build",
// Ask dotnet build to generate full paths for file names.
"/property:GenerateFullPaths=true",
// Do not generate summary otherwise it leads to duplicate errors in Problems panel
"/consoleloggerparameters:NoSummary"
],
"group": "build",
"presentation": {
"reveal": "silent"
},
"problemMatcher": "$msCompile"
}
]
}

View File

@@ -1,4 +1,4 @@
Copyright (c) 2016-2025 Matt Nadareski
Copyright (c) 2016-2026 Matt Nadareski
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

View File

@@ -65,6 +65,7 @@ Three reference feature implementations are included in the `SabreTools.CommandL
| --- | --- |
| `Help` | Outputs either a generic help text or one specific to a feature name included as the second argument |
| `HelpExtended` | Outputs either a generic help text with extended descriptions or one specific to a feature name included as the second argument |
| `Manpage` | Outputs a roff-formatted manpage based on the current command set |
| `Version` | Outputs the version number of the program, derived from the informational version |
## Releases

View File

@@ -1,4 +1,7 @@
using SabreTools.CommandLine.Inputs;
using System;
using System.Collections.Generic;
using System.IO;
using SabreTools.CommandLine.Inputs;
using Xunit;
namespace SabreTools.CommandLine.Test
@@ -11,9 +14,16 @@ namespace SabreTools.CommandLine.Test
var input1 = new FlagInput("input1", "--input1", "input1");
var input2 = new FlagInput("input2", "--input2", "input2");
var feature1 = new MockFeature("feature1", "feature1", "feature1");
var inputA = new FlagInput("inputA", "--inputA", "inputA");
var inputB = new FlagInput("inputB", "--inputB", "inputB");
feature1.Add(inputA);
feature1.Add(inputB);
var featureSet = new CommandSet();
featureSet.Add(input1);
featureSet.Add(input2);
featureSet.AddFrom(feature1);
var actualInput1 = featureSet["input1"];
Assert.NotNull(actualInput1);
@@ -25,6 +35,14 @@ namespace SabreTools.CommandLine.Test
var actualInput3 = featureSet["input3"];
Assert.Null(actualInput3);
var actualInputA = featureSet["inputA"];
Assert.NotNull(actualInputA);
Assert.Equal("inputA", actualInputA.Name);
var actualinputB = featureSet["inputB"];
Assert.NotNull(actualinputB);
Assert.Equal("inputB", actualinputB.Name);
}
[Fact]
@@ -90,5 +108,926 @@ namespace SabreTools.CommandLine.Test
bool actualTop3 = featureSet.IsTopLevel("input3");
Assert.False(actualTop3);
}
#region GetBoolean
[Fact]
public void GetBoolean_InvalidKey_DefaultValue()
{
var commandSet = new CommandSet();
var child = new BooleanInput("b", "b", "b");
commandSet.Add(child);
bool actual = commandSet.GetBoolean("c");
Assert.False(actual);
}
[Fact]
public void GetBoolean_Exists_WrongType_Throws()
{
var commandSet = new CommandSet();
var child = new MockUserInput("b", "b", "b");
commandSet.Add(child);
Assert.Throws<ArgumentException>(() => _ = commandSet.GetBoolean("b"));
}
[Fact]
public void GetBoolean_Exists_Returns()
{
var commandSet = new CommandSet();
var child = new BooleanInput("b", "b", "b");
commandSet.Add(child);
int index = 0;
child.ProcessInput(["b", "true"], ref index);
bool actual = commandSet.GetBoolean("b");
Assert.True(actual);
}
[Fact]
public void GetBoolean_NestedExists_Returns()
{
var commandSet = new CommandSet();
var child = new MockUserInput("b", "b", "b");
commandSet.Add(child);
var subChild = new BooleanInput("c", "c", "c");
child.Add(subChild);
int index = 0;
subChild.ProcessInput(["c", "true"], ref index);
bool actual = commandSet.GetBoolean("c");
Assert.True(actual);
}
#endregion
#region GetFeature
[Fact]
public void GetFeature_InvalidKey_Null()
{
var commandSet = new CommandSet();
var child = new MockFeature("b", "b", "b");
commandSet.Add(child);
Feature? actual = commandSet.GetFeature("c");
Assert.Null(actual);
}
[Fact]
public void GetFeature_Exists_WrongType_Throws()
{
var commandSet = new CommandSet();
var child = new MockUserInput("b", "b", "b");
commandSet.Add(child);
Assert.Throws<ArgumentException>(() => _ = commandSet.GetFeature("b"));
}
[Fact]
public void GetFeature_Exists_Returns()
{
var commandSet = new CommandSet();
var child = new MockFeature("b", "b", "b");
commandSet.Add(child);
int index = 0;
child.ProcessInput(["b"], ref index);
Feature? actual = commandSet.GetFeature("b");
Assert.NotNull(actual);
Assert.Equal("b", actual.Name);
Assert.True(actual.Value);
}
[Fact]
public void GetFeature_NestedExists_Returns()
{
var commandSet = new CommandSet();
var child = new MockUserInput("b", "b", "b");
commandSet.Add(child);
var subChild = new MockFeature("c", "c", "c");
child.Add(subChild);
int index = 0;
subChild.ProcessInput(["c"], ref index);
Feature? actual = commandSet.GetFeature("c");
Assert.NotNull(actual);
Assert.Equal("c", actual.Name);
Assert.True(actual.Value);
}
#endregion
#region GetInt8
[Fact]
public void GetInt8_InvalidKey_DefaultValue()
{
var commandSet = new CommandSet();
var child = new Int8Input("b", "b", "b");
commandSet.Add(child);
sbyte actual = commandSet.GetInt8("c");
Assert.Equal(sbyte.MinValue, actual);
}
[Fact]
public void GetInt8_Exists_WrongType_Throws()
{
var commandSet = new CommandSet();
var child = new MockUserInput("b", "b", "b");
commandSet.Add(child);
Assert.Throws<ArgumentException>(() => _ = commandSet.GetInt8("b"));
}
[Fact]
public void GetInt8_Exists_Returns()
{
var commandSet = new CommandSet();
var child = new Int8Input("b", "b", "b");
commandSet.Add(child);
int index = 0;
child.ProcessInput(["b", "5"], ref index);
sbyte actual = commandSet.GetInt8("b");
Assert.Equal(5, actual);
}
[Fact]
public void GetInt8_NestedExists_Returns()
{
var commandSet = new CommandSet();
var child = new MockUserInput("b", "b", "b");
commandSet.Add(child);
var subChild = new Int8Input("c", "c", "c");
child.Add(subChild);
int index = 0;
subChild.ProcessInput(["c", "5"], ref index);
sbyte actual = commandSet.GetInt8("c");
Assert.Equal(5, actual);
}
#endregion
#region GetInt16
[Fact]
public void GetInt16_InvalidKey_DefaultValue()
{
var commandSet = new CommandSet();
var child = new Int16Input("b", "b", "b");
commandSet.Add(child);
short actual = commandSet.GetInt16("c");
Assert.Equal(short.MinValue, actual);
}
[Fact]
public void GetInt16_Exists_WrongType_Throws()
{
var commandSet = new CommandSet();
var child = new MockUserInput("b", "b", "b");
commandSet.Add(child);
Assert.Throws<ArgumentException>(() => _ = commandSet.GetInt16("b"));
}
[Fact]
public void GetInt16_Exists_Returns()
{
var commandSet = new CommandSet();
var child = new Int16Input("b", "b", "b");
commandSet.Add(child);
int index = 0;
child.ProcessInput(["b", "5"], ref index);
short actual = commandSet.GetInt16("b");
Assert.Equal(5, actual);
}
[Fact]
public void GetInt16_NestedExists_Returns()
{
var commandSet = new CommandSet();
var child = new MockUserInput("b", "b", "b");
commandSet.Add(child);
var subChild = new Int16Input("c", "c", "c");
child.Add(subChild);
int index = 0;
subChild.ProcessInput(["c", "5"], ref index);
short actual = commandSet.GetInt16("c");
Assert.Equal(5, actual);
}
#endregion
#region GetInt32
[Fact]
public void GetInt32_InvalidKey_DefaultValue()
{
var commandSet = new CommandSet();
var child = new Int32Input("b", "b", "b");
commandSet.Add(child);
int actual = commandSet.GetInt32("c");
Assert.Equal(int.MinValue, actual);
}
[Fact]
public void GetInt32_Exists_WrongType_Throws()
{
var commandSet = new CommandSet();
var child = new MockUserInput("b", "b", "b");
commandSet.Add(child);
Assert.Throws<ArgumentException>(() => _ = commandSet.GetInt32("b"));
}
[Fact]
public void GetInt32_Exists_Returns()
{
var commandSet = new CommandSet();
var child = new Int32Input("b", "b", "b");
commandSet.Add(child);
int index = 0;
child.ProcessInput(["b", "5"], ref index);
int actual = commandSet.GetInt32("b");
Assert.Equal(5, actual);
}
[Fact]
public void GetInt32_NestedExists_Returns()
{
var commandSet = new CommandSet();
var child = new MockUserInput("b", "b", "b");
commandSet.Add(child);
var subChild = new Int32Input("c", "c", "c");
child.Add(subChild);
int index = 0;
subChild.ProcessInput(["c", "5"], ref index);
int actual = commandSet.GetInt32("c");
Assert.Equal(5, actual);
}
#endregion
#region GetInt64
[Fact]
public void GetInt64_InvalidKey_DefaultValue()
{
var commandSet = new CommandSet();
var child = new Int64Input("b", "b", "b");
commandSet.Add(child);
long actual = commandSet.GetInt64("c");
Assert.Equal(long.MinValue, actual);
}
[Fact]
public void GetInt64_Exists_WrongType_Throws()
{
var commandSet = new CommandSet();
var child = new MockUserInput("b", "b", "b");
commandSet.Add(child);
Assert.Throws<ArgumentException>(() => _ = commandSet.GetInt64("b"));
}
[Fact]
public void GetInt64_Exists_Returns()
{
var commandSet = new CommandSet();
var child = new Int64Input("b", "b", "b");
commandSet.Add(child);
int index = 0;
child.ProcessInput(["b", "5"], ref index);
long actual = commandSet.GetInt64("b");
Assert.Equal(5, actual);
}
[Fact]
public void GetInt64_NestedExists_Returns()
{
var commandSet = new CommandSet();
var child = new MockUserInput("b", "b", "b");
commandSet.Add(child);
var subChild = new Int64Input("c", "c", "c");
child.Add(subChild);
int index = 0;
subChild.ProcessInput(["c", "5"], ref index);
long actual = commandSet.GetInt64("c");
Assert.Equal(5, actual);
}
#endregion
#region GetString
[Fact]
public void GetString_InvalidKey_DefaultValue()
{
var commandSet = new CommandSet();
var child = new StringInput("b", "b", "b");
commandSet.Add(child);
string? actual = commandSet.GetString("c");
Assert.Null(actual);
}
[Fact]
public void GetString_Exists_WrongType_Throws()
{
var commandSet = new CommandSet();
var child = new MockUserInput("b", "b", "b");
commandSet.Add(child);
Assert.Throws<ArgumentException>(() => _ = commandSet.GetString("b"));
}
[Fact]
public void GetString_Exists_Returns()
{
var commandSet = new CommandSet();
var child = new StringInput("b", "b", "b");
commandSet.Add(child);
int index = 0;
child.ProcessInput(["b", "value"], ref index);
string? actual = commandSet.GetString("b");
Assert.Equal("value", actual);
}
[Fact]
public void GetString_NestedExists_Returns()
{
var commandSet = new CommandSet();
var child = new MockUserInput("b", "b", "b");
commandSet.Add(child);
var subChild = new StringInput("c", "c", "c");
child.Add(subChild);
int index = 0;
subChild.ProcessInput(["c", "value"], ref index);
string? actual = commandSet.GetString("c");
Assert.Equal("value", actual);
}
#endregion
#region GetStringList
[Fact]
public void GetStringList_InvalidKey_DefaultValue()
{
var commandSet = new CommandSet();
var child = new StringListInput("b", "b", "b");
commandSet.Add(child);
List<string> actual = commandSet.GetStringList("c");
Assert.Empty(actual);
}
[Fact]
public void GetStringList_Exists_WrongType_Throws()
{
var commandSet = new CommandSet();
var child = new MockUserInput("b", "b", "b");
commandSet.Add(child);
Assert.Throws<ArgumentException>(() => _ = commandSet.GetStringList("b"));
}
[Fact]
public void GetStringList_Exists_Returns()
{
var commandSet = new CommandSet();
var child = new StringListInput("b", "b", "b");
commandSet.Add(child);
int index = 0;
child.ProcessInput(["b", "value"], ref index);
List<string> actual = commandSet.GetStringList("b");
string value = Assert.Single(actual);
Assert.Equal("value", value);
}
[Fact]
public void GetStringList_NestedExists_Returns()
{
var commandSet = new CommandSet();
var child = new MockUserInput("b", "b", "b");
commandSet.Add(child);
var subChild = new StringListInput("c", "c", "c");
child.Add(subChild);
int index = 0;
subChild.ProcessInput(["c", "value"], ref index);
List<string> actual = commandSet.GetStringList("c");
string value = Assert.Single(actual);
Assert.Equal("value", value);
}
#endregion
#region GetUInt8
[Fact]
public void GetUInt8_InvalidKey_DefaultValue()
{
var commandSet = new CommandSet();
var child = new UInt8Input("b", "b", "b");
commandSet.Add(child);
byte actual = commandSet.GetUInt8("c");
Assert.Equal(byte.MinValue, actual);
}
[Fact]
public void GetUInt8_Exists_WrongType_Throws()
{
var commandSet = new CommandSet();
var child = new MockUserInput("b", "b", "b");
commandSet.Add(child);
Assert.Throws<ArgumentException>(() => _ = commandSet.GetUInt8("b"));
}
[Fact]
public void GetUInt8_Exists_Returns()
{
var commandSet = new CommandSet();
var child = new UInt8Input("b", "b", "b");
commandSet.Add(child);
int index = 0;
child.ProcessInput(["b", "5"], ref index);
byte actual = commandSet.GetUInt8("b");
Assert.Equal(5, actual);
}
[Fact]
public void GetUInt8_NestedExists_Returns()
{
var commandSet = new CommandSet();
var child = new MockUserInput("b", "b", "b");
commandSet.Add(child);
var subChild = new UInt8Input("c", "c", "c");
child.Add(subChild);
int index = 0;
subChild.ProcessInput(["c", "5"], ref index);
byte actual = commandSet.GetUInt8("c");
Assert.Equal(5, actual);
}
#endregion
#region GetUInt16
[Fact]
public void GetUInt16_InvalidKey_DefaultValue()
{
var commandSet = new CommandSet();
var child = new UInt16Input("b", "b", "b");
commandSet.Add(child);
ushort actual = commandSet.GetUInt16("c");
Assert.Equal(ushort.MinValue, actual);
}
[Fact]
public void GetUInt16_Exists_WrongType_Throws()
{
var commandSet = new CommandSet();
var child = new MockUserInput("b", "b", "b");
commandSet.Add(child);
Assert.Throws<ArgumentException>(() => _ = commandSet.GetUInt16("b"));
}
[Fact]
public void GetUInt16_Exists_Returns()
{
var commandSet = new CommandSet();
var child = new UInt16Input("b", "b", "b");
commandSet.Add(child);
int index = 0;
child.ProcessInput(["b", "5"], ref index);
ushort actual = commandSet.GetUInt16("b");
Assert.Equal(5, actual);
}
[Fact]
public void GetUInt16_NestedExists_Returns()
{
var commandSet = new CommandSet();
var child = new MockUserInput("b", "b", "b");
commandSet.Add(child);
var subChild = new UInt16Input("c", "c", "c");
child.Add(subChild);
int index = 0;
subChild.ProcessInput(["c", "5"], ref index);
ushort actual = commandSet.GetUInt16("c");
Assert.Equal(5, actual);
}
#endregion
#region GetUInt32
[Fact]
public void GetUInt32_InvalidKey_DefaultValue()
{
var commandSet = new CommandSet();
var child = new UInt32Input("b", "b", "b");
commandSet.Add(child);
uint actual = commandSet.GetUInt32("c");
Assert.Equal(uint.MinValue, actual);
}
[Fact]
public void GetUInt32_Exists_WrongType_Throws()
{
var commandSet = new CommandSet();
var child = new MockUserInput("b", "b", "b");
commandSet.Add(child);
Assert.Throws<ArgumentException>(() => _ = commandSet.GetUInt32("b"));
}
[Fact]
public void GetUInt32_Exists_Returns()
{
var commandSet = new CommandSet();
var child = new UInt32Input("b", "b", "b");
commandSet.Add(child);
int index = 0;
child.ProcessInput(["b", "5"], ref index);
uint actual = commandSet.GetUInt32("b");
Assert.Equal(5u, actual);
}
[Fact]
public void GetUInt32_NestedExists_Returns()
{
var commandSet = new CommandSet();
var child = new MockUserInput("b", "b", "b");
commandSet.Add(child);
var subChild = new UInt32Input("c", "c", "c");
child.Add(subChild);
int index = 0;
subChild.ProcessInput(["c", "5"], ref index);
uint actual = commandSet.GetUInt32("c");
Assert.Equal(5u, actual);
}
#endregion
#region GetUInt64
[Fact]
public void GetUInt64_InvalidKey_DefaultValue()
{
var commandSet = new CommandSet();
var child = new UInt64Input("b", "b", "b");
commandSet.Add(child);
ulong actual = commandSet.GetUInt64("c");
Assert.Equal(ulong.MinValue, actual);
}
[Fact]
public void GetUInt64_Exists_WrongType_Throws()
{
var commandSet = new CommandSet();
var child = new MockUserInput("b", "b", "b");
commandSet.Add(child);
Assert.Throws<ArgumentException>(() => _ = commandSet.GetUInt64("b"));
}
[Fact]
public void GetUInt64_Exists_Returns()
{
var commandSet = new CommandSet();
var child = new UInt64Input("b", "b", "b");
commandSet.Add(child);
int index = 0;
child.ProcessInput(["b", "5"], ref index);
ulong actual = commandSet.GetUInt64("b");
Assert.Equal(5u, actual);
}
[Fact]
public void GetUInt64_NestedExists_Returns()
{
var commandSet = new CommandSet();
var child = new MockUserInput("b", "b", "b");
commandSet.Add(child);
var subChild = new UInt64Input("c", "c", "c");
child.Add(subChild);
int index = 0;
subChild.ProcessInput(["c", "5"], ref index);
ulong actual = commandSet.GetUInt64("c");
Assert.Equal(5u, actual);
}
#endregion
#region ProcessArgs
[Fact]
public void ProcessArgs_EmptyArgs_Success()
{
var commandSet = new CommandSet();
string[] args = [];
bool actual = commandSet.ProcessArgs(args);
Assert.True(actual);
}
[Fact]
public void ProcessArgs_ValidArgs_Success()
{
var commandSet = new CommandSet();
Feature feature = new MockFeature("a", "a", "a");
feature.Add(new FlagInput("b", "b", "b"));
feature.Add(new FlagInput("c", "c", "c"));
commandSet.Add(feature);
string[] args = ["a", "b", "c"];
bool actual = commandSet.ProcessArgs(args);
Assert.True(actual);
Assert.Empty(feature.Inputs);
}
[Fact]
public void ProcessArgs_InvalidArg_AddedAsGeneric()
{
var commandSet = new CommandSet();
Feature feature = new MockFeature("a", "a", "a");
feature.Add(new FlagInput("b", "b", "b"));
feature.Add(new FlagInput("d", "d", "d"));
commandSet.Add(feature);
string[] args = ["a", "b", "c"];
bool actual = commandSet.ProcessArgs(args);
Assert.True(actual);
string input = Assert.Single(feature.Inputs);
Assert.Equal("c", input);
}
[Fact]
public void ProcessArgs_NestedArgs_Success()
{
var commandSet = new CommandSet();
Feature feature = new MockFeature("a", "a", "a");
var sub = new FlagInput("b", "b", "b");
sub.Add(new FlagInput("c", "c", "c"));
feature.Add(sub);
commandSet.Add(feature);
string[] args = ["a", "b", "c"];
bool actual = commandSet.ProcessArgs(args);
Assert.True(actual);
Assert.Empty(feature.Inputs);
}
#endregion
#region Manpage Output
[Fact]
public void OutputManpage_Structure_ContainsRequiredSections()
{
var set = BuildSampleSet();
var info = new ManpageInfo("sample")
{
Version = "sample 1.0",
Description = "do sample things",
};
string page = set.OutputManpage(info);
Assert.Contains(".TH \"SAMPLE\" \"1\"", page);
Assert.Contains(".SH NAME", page);
Assert.Contains("sample \\- do sample things", page);
Assert.Contains(".SH SYNOPSIS", page);
Assert.Contains(".SH DESCRIPTION", page);
Assert.Contains(".SH OPTIONS", page);
Assert.Contains(".TP", page);
}
[Fact]
public void OutputManpage_EscapesHyphensInFlags()
{
var set = BuildSampleSet();
var info = new ManpageInfo("sample");
string page = set.OutputManpage(info);
Assert.Contains(".B \\-\\-convert", page);
Assert.DoesNotContain(".B --convert", page);
}
[Fact]
public void OutputManpage_Verbose_TogglesDetailedText()
{
var set = BuildSampleSet();
var info = new ManpageInfo("sample");
string terse = set.OutputManpage(info, includeVerbose: false);
string verbose = set.OutputManpage(info, includeVerbose: true);
Assert.DoesNotContain("Built\\-in to most of the programs", terse);
Assert.Contains("Built\\-in to most of the programs", verbose);
}
[Fact]
public void OutputManpage_NestsChildren()
{
var set = BuildSampleSet();
var info = new ManpageInfo("sample");
string page = set.OutputManpage(info);
Assert.Contains(".RS", page);
Assert.Contains(".RE", page);
Assert.Contains(".B \\-\\-output", page);
}
[Fact]
public void OutputManpage_HonorsCustomOptionsHeading()
{
var set = BuildSampleSet();
var info = new ManpageInfo("sample") { OptionsHeading = "COMMANDS" };
string page = set.OutputManpage(info);
Assert.Contains(".SH COMMANDS", page);
Assert.DoesNotContain(".SH OPTIONS", page);
}
[Fact]
public void OutputManpage_LeavesBlankDateForStamping()
{
var set = BuildSampleSet();
var info = new ManpageInfo("sample") { Version = "sample 1.0" };
string page = set.OutputManpage(info);
// Section, then an empty date field, then the version
Assert.Contains(".TH \"SAMPLE\" \"1\" \"\" \"sample 1.0\"", page);
}
[Fact]
public void OutputManpage_LinesWithinWidth()
{
var set = BuildSampleSet();
var info = new ManpageInfo("sample") { Description = "do sample things" };
string page = set.OutputManpage(info, includeVerbose: true);
foreach (string line in page.Split('\n'))
{
Assert.True(line.Length <= 78, $"Line exceeds 78 columns: {line}");
}
}
[Fact]
public void OutputManpage_FileOverload_WritesSameContent()
{
var set = BuildSampleSet();
var info = new ManpageInfo("sample");
string expected = set.OutputManpage(info);
string path = Path.Combine(Path.GetTempPath(), $"sabretools-manpage-{Guid.NewGuid():N}.1");
try
{
set.OutputManpage(path, info);
string actual = File.ReadAllText(path);
Assert.Equal(expected, actual);
}
finally
{
if (File.Exists(path))
File.Delete(path);
}
}
#endregion
/// <summary>
/// Build a representative command set with nested inputs and detail text
/// </summary>
private static CommandSet BuildSampleSet()
{
var set = new CommandSet("Sample header line");
var help = new MockFeature("Help",
["?", "h", "help"],
"Show this help",
"Built-in to most of the programs is a basic help text.");
set.Add(help);
var convert = new MockFeature("Convert",
"--convert",
"Convert input files",
"Converts the provided input files into the desired output format.");
convert.Add(new StringInput("output", "--output", "Set the output path"));
convert.Add(new FlagInput("force", "--force", "Overwrite existing files"));
set.Add(convert);
return set;
}
/// <summary>
/// Mock Feature implementation for testing
/// </summary>
private class MockFeature : Feature
{
public MockFeature(string name, string flag, string description, string? detailed = null)
: base(name, flag, description, detailed)
{
}
public MockFeature(string name, string[] flags, string description, string? detailed = null)
: base(name, flags, description, detailed)
{
}
/// <inheritdoc/>
public override bool Execute() => true;
/// <inheritdoc/>
public override bool VerifyInputs() => true;
}
/// <summary>
/// Mock UserInput implementation for testing
/// </summary>
private class MockUserInput : UserInput<object?>
{
public MockUserInput(string name, string flag, string description, string? detailed = null)
: base(name, flag, description, detailed)
{
}
public MockUserInput(string name, string[] flags, string description, string? detailed = null)
: base(name, flags, description, detailed)
{
}
/// <inheritdoc/>
public override bool ProcessInput(string[] args, ref int index) => true;
/// <inheritdoc/>
protected override string FormatFlags() => string.Empty;
}
}
}

View File

@@ -0,0 +1,83 @@
using System;
using System.IO;
using SabreTools.CommandLine.Features;
using SabreTools.CommandLine.Inputs;
using Xunit;
namespace SabreTools.CommandLine.Test.Features
{
public class ManpageTests
{
[Fact]
public void ManpageFeature_WritesPageToStandardOutput()
{
var set = BuildSampleSet();
var info = new ManpageInfo("sample") { Version = "sample 1.0" };
set.Add(new Manpage(info));
var original = Console.Out;
var writer = new StringWriter();
try
{
Console.SetOut(writer);
bool result = set.ProcessArgs(["man"]);
Assert.True(result);
}
finally
{
Console.SetOut(original);
}
string actual = writer.ToString();
Assert.Contains(".TH \"SAMPLE\" \"1\"", actual);
Assert.Contains(".SH NAME", actual);
Assert.Contains(".B \\-\\-convert", actual);
}
/// <summary>
/// Build a representative command set with nested inputs and detail text
/// </summary>
private static CommandSet BuildSampleSet()
{
var set = new CommandSet("Sample header line");
var help = new MockFeature("Help",
["?", "h", "help"],
"Show this help",
"Built-in to most of the programs is a basic help text.");
set.Add(help);
var convert = new MockFeature("Convert",
"--convert",
"Convert input files",
"Converts the provided input files into the desired output format.");
convert.Add(new StringInput("output", "--output", "Set the output path"));
convert.Add(new FlagInput("force", "--force", "Overwrite existing files"));
set.Add(convert);
return set;
}
/// <summary>
/// Mock Feature implementation for testing
/// </summary>
private class MockFeature : Feature
{
public MockFeature(string name, string flag, string description, string? detailed = null)
: base(name, flag, description, detailed)
{
}
public MockFeature(string name, string[] flags, string description, string? detailed = null)
: base(name, flags, description, detailed)
{
}
/// <inheritdoc/>
public override bool Execute() => true;
/// <inheritdoc/>
public override bool VerifyInputs() => true;
}
}
}

View File

@@ -114,6 +114,65 @@ namespace SabreTools.CommandLine.Test.Inputs
#endregion
#region GetFeature
[Fact]
public void GetFeature_InvalidKey_Null()
{
UserInput userInput = new MockUserInput("a", "a", "a");
var child = new MockFeature("b", "b", "b");
userInput.Add(child);
Feature? actual = userInput.GetFeature("c");
Assert.Null(actual);
}
[Fact]
public void GetFeature_Exists_WrongType_Throws()
{
UserInput userInput = new MockUserInput("a", "a", "a");
var child = new MockUserInput("b", "b", "b");
userInput.Add(child);
Assert.Throws<ArgumentException>(() => _ = userInput.GetFeature("b"));
}
[Fact]
public void GetFeature_Exists_Returns()
{
UserInput userInput = new MockUserInput("a", "a", "a");
var child = new MockFeature("b", "b", "b");
userInput.Add(child);
int index = 0;
child.ProcessInput(["b"], ref index);
Feature? actual = userInput.GetFeature("b");
Assert.NotNull(actual);
Assert.Equal("b", actual.Name);
Assert.True(actual.Value);
}
[Fact]
public void GetFeature_NestedExists_Returns()
{
UserInput userInput = new MockUserInput("a", "a", "a");
var child = new MockUserInput("b", "b", "b");
userInput.Add(child);
var subChild = new MockFeature("c", "c", "c");
child.Add(subChild);
int index = 0;
subChild.ProcessInput(["c"], ref index);
Feature? actual = userInput.GetFeature("c");
Assert.NotNull(actual);
Assert.Equal("c", actual.Name);
Assert.True(actual.Value);
}
#endregion
#region GetInt8
[Fact]
@@ -666,6 +725,28 @@ namespace SabreTools.CommandLine.Test.Inputs
#endregion
/// <summary>
/// Mock Feature implementation for testing
/// </summary>
private class MockFeature : Feature
{
public MockFeature(string name, string flag, string description, string? detailed = null)
: base(name, flag, description, detailed)
{
}
public MockFeature(string name, string[] flags, string description, string? detailed = null)
: base(name, flags, description, detailed)
{
}
/// <inheritdoc/>
public override bool Execute() => true;
/// <inheritdoc/>
public override bool VerifyInputs() => true;
}
/// <summary>
/// Mock UserInput implementation for testing
/// </summary>

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net8.0;net9.0</TargetFrameworks>
<TargetFrameworks>net8.0;net9.0;net10.0;net11.0</TargetFrameworks>
<IsPackable>false</IsPackable>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
@@ -12,13 +12,13 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<PackageReference Include="coverlet.collector" Version="10.1.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>

View File

@@ -1,6 +1,9 @@
using System;
using System.Collections.Generic;
using System.IO;
using SabreTools.CommandLine.Features;
using SabreTools.CommandLine.Inputs;
using SabreTools.CommandLine.Tools;
namespace SabreTools.CommandLine
{
@@ -39,6 +42,41 @@ namespace SabreTools.CommandLine
#endregion
#region Properties
/// <summary>
/// Custom help text to print instead of the automatically
/// generated generic help
/// </summary>
/// <remarks>
/// This only replaces the automatically generated help
/// for <see cref="OutputGenericHelp"/>. It does not impact
/// either <see cref="OutputAllHelp"/> or <see cref="OutputFeatureHelp"/>.
/// </remarks>
public string? CustomHelp { get; set; }
/// <summary>
/// Feature that represents the default functionality
/// for a command set
/// </summary>
/// <remarks>
/// It is recommended to use this in applications that
/// do not need multiple distinct functional modes.
///
/// When printing help text, the flags and description
/// of this feature will be omitted, instead printing
/// the children of the feature directly at the
/// top level.
///
/// If the default feature is included as a normal
/// top-level input for the command set, then the flags
/// will be printed twice - once under the feature
/// itself and once at the top level.
/// </remarks>
public Feature? DefaultFeature { get; set; }
#endregion
#region Constructors
/// <summary>
@@ -46,6 +84,15 @@ namespace SabreTools.CommandLine
/// </summary>
public CommandSet() { }
/// <summary>
/// Create a new CommandSet with a printable header
/// </summary>
/// <param name="header">Custom commandline header to be printed when outputting help</param>
public CommandSet(string header)
{
_header.Add(header);
}
/// <summary>
/// Create a new CommandSet with a printable header
/// </summary>
@@ -55,6 +102,17 @@ namespace SabreTools.CommandLine
_header.AddRange(header);
}
/// <summary>
/// Create a new CommandSet with a printable header
/// </summary>
/// <param name="header">Custom commandline header to be printed when outputting help</param>
/// <param name="footer">Custom commandline footer to be printed when outputting help</param>
public CommandSet(string header, string footer)
{
_header.Add(header);
_footer.Add(footer);
}
/// <summary>
/// Create a new CommandSet with a printable header
/// </summary>
@@ -99,6 +157,565 @@ namespace SabreTools.CommandLine
public void Add(UserInput input)
=> _inputs.Add(input.Name, input);
/// <summary>
/// Add all children from an input to the set
/// </summary>
/// <param name="input">UserInput object to retrieve children from</param>
/// <remarks>
/// This should only be used in situations where an input is defined
/// but not used within the context of the command set directly.
///
/// This is helpful for when there are applications with default functionality
/// that need to be able to expose both defined features as well as
/// the default functionality in help text.
///
/// If there is any overlap between existing names and the names from
/// any of the children, this operation will overwrite them.
/// </reamrks>
public void AddFrom(UserInput input)
{
foreach (var kvp in input.Children)
{
_inputs.Add(kvp.Key, kvp.Value);
}
}
#endregion
#region Children
/// <summary>
/// Get a boolean value from a named input
/// </summary>
/// <param name="key">Input name to retrieve, if possible</param>
/// <param name="defaultValue">Optional default value if not found</param>
/// <returns>The value if found, the default value otherwise</returns>
public bool GetBoolean(string key, bool defaultValue = false)
{
if (TryGetBoolean(key, out bool value, defaultValue))
return value;
return defaultValue;
}
/// <summary>
/// Get a Feature value from a named input
/// </summary>
/// <param name="key">Input name to retrieve, if possible</param>
/// <returns>The value if found, null otherwise</returns>
public Feature? GetFeature(string key)
{
if (TryGetFeature(key, out Feature? value))
return value;
return null;
}
/// <summary>
/// Get an Int8 value from a named input
/// </summary>
/// <param name="key">Input name to retrieve, if possible</param>
/// <param name="defaultValue">Optional default value if not found</param>
/// <returns>The value if found, the default value otherwise</returns>
public sbyte GetInt8(string key, sbyte defaultValue = sbyte.MinValue)
{
if (TryGetInt8(key, out sbyte value, defaultValue))
return value;
return defaultValue;
}
/// <summary>
/// Get an Int16 value from a named input
/// </summary>
/// <param name="key">Input name to retrieve, if possible</param>
/// <param name="defaultValue">Optional default value if not found</param>
/// <returns>The value if found, the default value otherwise</returns>
public short GetInt16(string key, short defaultValue = short.MinValue)
{
if (TryGetInt16(key, out short value, defaultValue))
return value;
return defaultValue;
}
/// <summary>
/// Get an Int32 value from a named input
/// </summary>
/// <param name="key">Input name to retrieve, if possible</param>
/// <param name="defaultValue">Optional default value if not found</param>
/// <returns>The value if found, the default value otherwise</returns>
public int GetInt32(string key, int defaultValue = int.MinValue)
{
if (TryGetInt32(key, out int value, defaultValue))
return value;
return defaultValue;
}
/// <summary>
/// Get an Int64 value from a named input
/// </summary>
/// <param name="key">Input name to retrieve, if possible</param>
/// <param name="defaultValue">Optional default value if not found</param>
/// <returns>The value if found, the default value otherwise</returns>
public long GetInt64(string key, long defaultValue = long.MinValue)
{
if (TryGetInt64(key, out long value, defaultValue))
return value;
return defaultValue;
}
/// <summary>
/// Get a string value from a named input
/// </summary>
/// <param name="key">Input name to retrieve, if possible</param>
/// <param name="defaultValue">Optional default value if not found</param>
/// <returns>The value if found, the default value otherwise</returns>
public string? GetString(string key, string? defaultValue = null)
{
if (TryGetString(key, out string? value, defaultValue))
return value;
return defaultValue;
}
/// <summary>
/// Get a string list value from a named input
/// </summary>
/// <param name="key">Input name to retrieve, if possible</param>
/// <param name="defaultValue">Optional default value if not found</param>
/// <returns>The value if found, the default value otherwise</returns>
public List<string> GetStringList(string key)
{
if (TryGetStringList(key, out var value))
return value;
return [];
}
/// <summary>
/// Get a UInt8 value from a named input
/// </summary>
/// <param name="key">Input name to retrieve, if possible</param>
/// <param name="defaultValue">Optional default value if not found</param>
/// <returns>The value if found, the default value otherwise</returns>
public byte GetUInt8(string key, byte defaultValue = byte.MinValue)
{
if (TryGetUInt8(key, out byte value, defaultValue))
return value;
return defaultValue;
}
/// <summary>
/// Get a UInt16 value from a named input
/// </summary>
/// <param name="key">Input name to retrieve, if possible</param>
/// <param name="defaultValue">Optional default value if not found</param>
/// <returns>The value if found, the default value otherwise</returns>
public ushort GetUInt16(string key, ushort defaultValue = ushort.MinValue)
{
if (TryGetUInt16(key, out ushort value, defaultValue))
return value;
return defaultValue;
}
/// <summary>
/// Get a UInt32 value from a named input
/// </summary>
/// <param name="key">Input name to retrieve, if possible</param>
/// <param name="defaultValue">Optional default value if not found</param>
/// <returns>The value if found, the default value otherwise</returns>
public uint GetUInt32(string key, uint defaultValue = uint.MinValue)
{
if (TryGetUInt32(key, out uint value, defaultValue))
return value;
return defaultValue;
}
/// <summary>
/// Get a UInt64 value from a named input
/// </summary>
/// <param name="key">Input name to retrieve, if possible</param>
/// <param name="defaultValue">Optional default value if not found</param>
/// <returns>The value if found, the default value otherwise</returns>
public ulong GetUInt64(string key, ulong defaultValue = ulong.MinValue)
{
if (TryGetUInt64(key, out ulong value, defaultValue))
return value;
return defaultValue;
}
/// <summary>
/// Get a boolean value from a named input
/// </summary>
/// <param name="key">Input name to retrieve, if possible</param>
/// <param name="value">Value that was found, default value otherwise</param>
/// <param name="defaultValue">Optional default value if not found</param>
/// <returns>True if the value was found, false otherwise</returns>
public bool TryGetBoolean(string key, out bool value, bool defaultValue = false)
{
// Try to check immediate children
if (_inputs.TryGetValue(key, out var input))
{
if (input is BooleanInput b)
{
value = b.Value ?? defaultValue;
return true;
}
else if (input is FlagInput f)
{
value = f.Value;
return true;
}
throw new ArgumentException("Input is not a bool");
}
// Check all children recursively
foreach (var child in _inputs.Values)
{
if (child.TryGetBoolean(key, out value, defaultValue))
return true;
}
value = defaultValue;
return false;
}
/// <summary>
/// Get a Feature value from a named input
/// </summary>
/// <param name="key">Input name to retrieve, if possible</param>
/// <param name="value">Value that was found, default value otherwise</param>
/// <returns>True if the value was found, false otherwise</returns>
public bool TryGetFeature(string key, out Feature? value)
{
// Try to check immediate children
if (_inputs.TryGetValue(key, out var input))
{
if (input is not Feature i)
throw new ArgumentException("Input is not a Feature");
value = i;
return true;
}
// Check all children recursively
foreach (var child in _inputs.Values)
{
if (child.TryGetFeature(key, out value))
return true;
}
value = null;
return false;
}
/// <summary>
/// Get an Int8 value from a named input
/// </summary>
/// <param name="key">Input name to retrieve, if possible</param>
/// <param name="value">Value that was found, default value otherwise</param>
/// <param name="defaultValue">Optional default value if not found</param>
/// <returns>True if the value was found, false otherwise</returns>
public bool TryGetInt8(string key, out sbyte value, sbyte defaultValue = sbyte.MinValue)
{
// Try to check immediate children
if (_inputs.TryGetValue(key, out var input))
{
if (input is not Int8Input i)
throw new ArgumentException("Input is not an sbyte");
value = i.Value ?? defaultValue;
return true;
}
// Check all children recursively
foreach (var child in _inputs.Values)
{
if (child.TryGetInt8(key, out value, defaultValue))
return true;
}
value = defaultValue;
return false;
}
/// <summary>
/// Get an Int16 value from a named input
/// </summary>
/// <param name="key">Input name to retrieve, if possible</param>
/// <param name="value">Value that was found, default value otherwise</param>
/// <param name="defaultValue">Optional default value if not found</param>
/// <returns>True if the value was found, false otherwise</returns>
public bool TryGetInt16(string key, out short value, short defaultValue = short.MinValue)
{
// Try to check immediate children
if (_inputs.TryGetValue(key, out var input))
{
if (input is not Int16Input i)
throw new ArgumentException("Input is not a short");
value = i.Value ?? defaultValue;
return true;
}
// Check all children recursively
foreach (var child in _inputs.Values)
{
if (child.TryGetInt16(key, out value, defaultValue))
return true;
}
value = defaultValue;
return false;
}
/// <summary>
/// Get an Int32 value from a named input
/// </summary>
/// <param name="key">Input name to retrieve, if possible</param>
/// <param name="value">Value that was found, default value otherwise</param>
/// <param name="defaultValue">Optional default value if not found</param>
/// <returns>True if the value was found, false otherwise</returns>
public bool TryGetInt32(string key, out int value, int defaultValue = int.MinValue)
{
// Try to check immediate children
if (_inputs.TryGetValue(key, out var input))
{
if (input is not Int32Input i)
throw new ArgumentException("Input is not an int");
value = i.Value ?? defaultValue;
return true;
}
// Check all children recursively
foreach (var child in _inputs.Values)
{
if (child.TryGetInt32(key, out value, defaultValue))
return true;
}
value = defaultValue;
return false;
}
/// <summary>
/// Get an Int64 value from a named input
/// </summary>
/// <param name="key">Input name to retrieve, if possible</param>
/// <param name="value">Value that was found, default value otherwise</param>
/// <param name="defaultValue">Optional default value if not found</param>
/// <returns>True if the value was found, false otherwise</returns>
public bool TryGetInt64(string key, out long value, long defaultValue = long.MinValue)
{
// Try to check immediate children
if (_inputs.TryGetValue(key, out var input))
{
if (input is not Int64Input l)
throw new ArgumentException("Input is not a long");
value = l.Value ?? defaultValue;
return true;
}
// Check all children recursively
foreach (var child in _inputs.Values)
{
if (child.TryGetInt64(key, out value, defaultValue))
return true;
}
value = defaultValue;
return false;
}
/// <summary>
/// Get a string value from a named input
/// </summary>
/// <param name="key">Input name to retrieve, if possible</param>
/// <param name="value">Value that was found, default value otherwise</param>
/// <param name="defaultValue">Optional default value if not found</param>
/// <returns>True if the value was found, false otherwise</returns>
public bool TryGetString(string key, out string? value, string? defaultValue = null)
{
// Try to check immediate children
if (_inputs.TryGetValue(key, out var input))
{
if (input is not StringInput s)
throw new ArgumentException("Input is not a string");
value = s.Value ?? defaultValue;
return true;
}
// Check all children recursively
foreach (var child in _inputs.Values)
{
if (child.TryGetString(key, out value, defaultValue))
return true;
}
value = defaultValue;
return false;
}
/// <summary>
/// Get a string list value from a named input
/// </summary>
/// <param name="key">Input name to retrieve, if possible</param>
/// <param name="value">Value that was found, default value otherwise</param>
/// <returns>True if the value was found, false otherwise</returns>
public bool TryGetStringList(string key, out List<string> value)
{
// Try to check immediate children
if (_inputs.TryGetValue(key, out var input))
{
if (input is not StringListInput l)
throw new ArgumentException("Input is not a list");
value = l.Value ?? [];
return true;
}
// Check all children recursively
foreach (var child in _inputs.Values)
{
if (child.TryGetStringList(key, out value))
return true;
}
value = [];
return false;
}
/// <summary>
/// Get a UInt8 value from a named input
/// </summary>
/// <param name="key">Input name to retrieve, if possible</param>
/// <param name="value">Value that was found, default value otherwise</param>
/// <param name="defaultValue">Optional default value if not found</param>
/// <returns>True if the value was found, false otherwise</returns>
public bool TryGetUInt8(string key, out byte value, byte defaultValue = byte.MinValue)
{
// Try to check immediate children
if (_inputs.TryGetValue(key, out var input))
{
if (input is not UInt8Input i)
throw new ArgumentException("Input is not an byte");
value = i.Value ?? defaultValue;
return true;
}
// Check all children recursively
foreach (var child in _inputs.Values)
{
if (child.TryGetUInt8(key, out value, defaultValue))
return true;
}
value = defaultValue;
return false;
}
/// <summary>
/// Get a UInt16 value from a named input
/// </summary>
/// <param name="key">Input name to retrieve, if possible</param>
/// <param name="value">Value that was found, default value otherwise</param>
/// <param name="defaultValue">Optional default value if not found</param>
/// <returns>True if the value was found, false otherwise</returns>
public bool TryGetUInt16(string key, out ushort value, ushort defaultValue = ushort.MinValue)
{
// Try to check immediate children
if (_inputs.TryGetValue(key, out var input))
{
if (input is not UInt16Input i)
throw new ArgumentException("Input is not a ushort");
value = i.Value ?? defaultValue;
return true;
}
// Check all children recursively
foreach (var child in _inputs.Values)
{
if (child.TryGetUInt16(key, out value, defaultValue))
return true;
}
value = defaultValue;
return false;
}
/// <summary>
/// Get a UInt32 value from a named input
/// </summary>
/// <param name="key">Input name to retrieve, if possible</param>
/// <param name="value">Value that was found, default value otherwise</param>
/// <param name="defaultValue">Optional default value if not found</param>
/// <returns>True if the value was found, false otherwise</returns>
public bool TryGetUInt32(string key, out uint value, uint defaultValue = uint.MinValue)
{
// Try to check immediate children
if (_inputs.TryGetValue(key, out var input))
{
if (input is not UInt32Input i)
throw new ArgumentException("Input is not an uint");
value = i.Value ?? defaultValue;
return true;
}
// Check all children recursively
foreach (var child in _inputs.Values)
{
if (child.TryGetUInt32(key, out value, defaultValue))
return true;
}
value = defaultValue;
return false;
}
/// <summary>
/// Get a UInt64 value from a named input
/// </summary>
/// <param name="key">Input name to retrieve, if possible</param>
/// <param name="value">Value that was found, default value otherwise</param>
/// <param name="defaultValue">Optional default value if not found</param>
/// <returns>True if the value was found, false otherwise</returns>
public bool TryGetUInt64(string key, out ulong value, ulong defaultValue = ulong.MinValue)
{
// Try to check immediate children
if (_inputs.TryGetValue(key, out var input))
{
if (input is not UInt64Input l)
throw new ArgumentException("Input is not a ulong");
value = l.Value ?? defaultValue;
return true;
}
// Check all children recursively
foreach (var child in _inputs.Values)
{
if (child.TryGetUInt64(key, out value, defaultValue))
return true;
}
value = defaultValue;
return false;
}
#endregion
#region Inputs
@@ -163,7 +780,8 @@ namespace SabreTools.CommandLine
/// <summary>
/// Output top-level features only
/// </summary>
public void OutputGenericHelp()
/// <param name="detailed">True if the detailed descriptions should be formatted and output, false otherwise</param>
public void OutputGenericHelp(bool detailed = false)
{
// Start building the output list
List<string> output = [];
@@ -172,13 +790,34 @@ namespace SabreTools.CommandLine
if (_header.Count > 0)
output.AddRange(_header);
// Now append all available top-level flags
output.Add("Available options:");
foreach (var input in _inputs.Values)
// If custom help text is defined
if (CustomHelp is not null)
{
var outputs = input.Format(pre: 2, midpoint: 30);
if (outputs != null)
output.AddRange(outputs);
string customHelp = CustomHelp.Replace("\r\n", "\n");
string[] customLines = customHelp.Split('\n');
output.AddRange(customLines);
}
else
{
// Append all available top-level flags
output.Add("Available options:");
foreach (var input in _inputs.Values)
{
var outputs = input.Format(pre: 2, midpoint: 30, detailed);
if (outputs is not null)
output.AddRange(outputs);
}
// If there is a default feature
if (DefaultFeature is not null)
{
foreach (var input in DefaultFeature.Children)
{
var outputs = input.Value.Format(pre: 2, midpoint: 30, detailed);
if (outputs is not null)
output.AddRange(outputs);
}
}
}
// Append the footer, if needed
@@ -192,7 +831,8 @@ namespace SabreTools.CommandLine
/// <summary>
/// Output all features recursively
/// </summary>
public void OutputAllHelp()
/// <param name="detailed">True if the detailed descriptions should be formatted and output, false otherwise</param>
public void OutputAllHelp(bool detailed = false)
{
// Start building the output list
List<string> output = [];
@@ -201,15 +841,26 @@ namespace SabreTools.CommandLine
if (_header.Count > 0)
output.AddRange(_header);
// Now append all available flags recursively
// Append all available flags recursively
output.Add("Available options:");
foreach (var input in _inputs.Values)
{
var outputs = input.FormatRecursive(pre: 2, midpoint: 30, detailed: true);
if (outputs != null)
var outputs = input.FormatRecursive(pre: 2, midpoint: 30, detailed);
if (outputs is not null)
output.AddRange(outputs);
}
// If there is a default feature
if (DefaultFeature is not null)
{
foreach (var input in DefaultFeature.Children)
{
var outputs = input.Value.Format(pre: 2, midpoint: 30, detailed);
if (outputs is not null)
output.AddRange(outputs);
}
}
// Append the footer, if needed
if (_footer.Count > 0)
output.AddRange(_footer);
@@ -313,5 +964,190 @@ namespace SabreTools.CommandLine
}
#endregion
#region Manpage Output
/// <summary>
/// Generate a man page from the current set of inputs
/// </summary>
/// <param name="info">Document-level metadata for the man page</param>
/// <param name="includeVerbose">True if detailed descriptions should be included, false otherwise</param>
/// <returns>The complete man page as a roff-formatted string</returns>
/// <remarks>
/// The output uses only the portable man(7) macro set so that it
/// renders without warnings under both groff and mandoc. The page
/// content is derived from the same model used to format help text,
/// so it stays in sync with the output of the help features.
/// </remarks>
public string OutputManpage(ManpageInfo info, bool includeVerbose = false)
{
// Start building the output list
List<string> output = [];
// Leading comment and title line
output.Add(".\\\" Generated from the command-line model. Do not edit by hand.");
output.Add(BuildTitleHeader(info));
// Name section
output.Add(".SH NAME");
string nameLine = Roff.Escape(info.Name);
if (!string.IsNullOrEmpty(info.Description))
nameLine += $" \\- {Roff.Escape(info.Description)}";
output.Add(nameLine);
// Synopsis section
output.Add(".SH SYNOPSIS");
output.Add(item: $".B {Roff.Escape(info.Name)}");
output.Add("[options]");
// Description section, derived from the header lines
if (_header.Count > 0)
{
output.Add(".SH DESCRIPTION");
foreach (string line in _header)
{
output.AddRange(Roff.FormatText(line));
}
}
// Options section, derived from all available inputs
output.Add($".SH {info.OptionsHeading}");
foreach (var input in _inputs.Values)
{
output.AddRange(input.FormatManpage(includeVerbose));
}
// If there is a default feature, include its children directly
if (DefaultFeature is not null)
{
foreach (var input in DefaultFeature.Children.Values)
{
output.AddRange(input.FormatManpage(includeVerbose));
}
}
// Notes section, derived from the footer lines
if (_footer.Count > 0)
{
output.Add(".SH NOTES");
foreach (string line in _footer)
{
output.AddRange(Roff.FormatText(line));
}
}
// Join the lines with a trailing newline for a well-formed file
return string.Join("\n", [.. output]) + "\n";
}
/// <summary>
/// Generate a man page from the current set of inputs and write it to a file
/// </summary>
/// <param name="path">Path to write the generated man page to</param>
/// <param name="info">Document-level metadata for the man page</param>
/// <param name="includeVerbose">True if detailed descriptions should be included, false otherwise</param>
/// <remarks>The file is written as UTF-8 without a byte order mark</remarks>
public void OutputManpage(string path, ManpageInfo info, bool includeVerbose = false)
{
string content = OutputManpage(info, includeVerbose);
File.WriteAllText(path, content);
}
/// <summary>
/// Build the roff <c>.TH</c> title line from the page metadata
/// </summary>
/// <param name="info">Document-level metadata for the man page</param>
/// <returns>The formatted title line</returns>
private static string BuildTitleHeader(ManpageInfo info)
{
return ".TH "
+ Quote(info.Name.ToUpperInvariant())
+ " " + Quote(info.Section)
+ " " + Quote(info.Date)
+ " " + Quote(info.Version)
+ " " + Quote(info.Title);
}
/// <summary>
/// Wrap an escaped value in double quotes for a <c>.TH</c> field
/// </summary>
/// <param name="value">Value to quote, if any</param>
/// <returns>The escaped, quoted value</returns>
private static string Quote(string? value)
=> $"\"{Roff.EscapeField(value)}\"";
#endregion
#region Processing
/// <summary>
/// Process args list with default handling
/// </summary>
/// <param name="args">Set of arguments to process</param>
/// <returns>True if all arguments were processed correctly, false otherwise</returns>
/// <remarks>
/// This default processing implementation assumes a few key points:
/// - Top-level items are all <see cref="Feature"/>
/// - There is only top-level item allowed at a time
/// - The first argument is always the <see cref="Feature"/> flag
/// </remarks>
public bool ProcessArgs(string[] args)
{
// If there's no arguments, show help
if (args.Length == 0)
{
OutputAllHelp();
return true;
}
// Get the first argument as a feature flag
string featureName = args[0];
// Get the associated feature
var topLevel = GetTopLevel(featureName);
if (topLevel is null || topLevel is not Feature feature)
{
Console.WriteLine($"'{featureName}' is not valid feature flag");
OutputFeatureHelp(featureName);
return false;
}
// Handle default help functionality
if (topLevel is Help helpFeature)
{
helpFeature.ProcessArgs(args, 0, this);
return true;
}
else if (topLevel is HelpExtended helpExtFeature)
{
helpExtFeature.ProcessArgs(args, 0, this);
return true;
}
else if (topLevel is Manpage manFeature)
{
manFeature.ProcessArgs(args, 0, this);
return true;
}
// Now verify that all other flags are valid
if (!feature.ProcessArgs(args, 1))
{
OutputFeatureHelp(topLevel.Name);
return false;
}
// If inputs are required
if (feature.RequiresInputs && !feature.VerifyInputs())
{
OutputFeatureHelp(topLevel.Name);
return false;
}
// Now execute the current feature
return feature.Execute();
}
#endregion
}
}

View File

@@ -0,0 +1,72 @@
using System;
namespace SabreTools.CommandLine.Features
{
/// <summary>
/// Reference feature that emits a man page for the enclosing command set
/// </summary>
/// <remarks>
/// This mirrors the built-in <see cref="Help"/> feature: it must be given
/// the enclosing <see cref="CommandSet"/> so that it can render the page
/// from the same model used to format help text. The roff output is
/// written to standard output so that it can be redirected to a file at
/// packaging time, for example <c>tool man &gt; tool.1</c>.
/// </remarks>
public class Manpage : Feature
{
public const string DisplayName = "Manpage";
private static readonly string[] _defaultFlags = ["man"];
private const string _description = "Generate a man page";
private const string _detailedDescription = "Write a roff-formatted man page for this program to standard output.";
/// <summary>
/// Document-level metadata for the generated man page
/// </summary>
private readonly ManpageInfo _info;
/// <summary>
/// Indicates if detailed descriptions should be included
/// </summary>
private readonly bool _includeVerbose;
public Manpage(ManpageInfo info, bool includeVerbose = true)
: base(DisplayName, _defaultFlags, _description, _detailedDescription)
{
_info = info;
_includeVerbose = includeVerbose;
RequiresInputs = false;
}
public Manpage(ManpageInfo info, string[] flags, bool includeVerbose = true)
: base(DisplayName, flags, _description, _detailedDescription)
{
_info = info;
_includeVerbose = includeVerbose;
RequiresInputs = false;
}
/// <inheritdoc/>
public override bool ProcessArgs(string[] args, int index)
=> ProcessArgs(args, index, null);
/// <inheritdoc cref="ProcessArgs(string[], int)"/>
/// <param name="parentSet">Reference to the enclosing parent set</param>
public bool ProcessArgs(string[] args, int index, CommandSet? parentSet)
{
// The page can only be rendered with the enclosing set
if (parentSet is not null)
Console.Write(parentSet.OutputManpage(_info, _includeVerbose));
return true;
}
/// <inheritdoc/>
public override bool VerifyInputs() => true;
/// <inheritdoc/>
public override bool Execute() => true;
}
}

View File

@@ -46,7 +46,7 @@ namespace SabreTools.CommandLine.Features
try
{
var assembly = Assembly.GetEntryAssembly();
if (assembly == null)
if (assembly is null)
return null;
var assemblyVersion = Attribute.GetCustomAttribute(assembly, typeof(AssemblyInformationalVersionAttribute)) as AssemblyInformationalVersionAttribute;

View File

@@ -3,7 +3,7 @@ using System.Text;
namespace SabreTools.CommandLine.Inputs
{
/// <summary>
/// Represents a user input bounded to the range of <see cref="bool"/>
/// Represents a user input bounded to the range of <see cref="bool"/>
/// </summary>
public class BooleanInput : UserInput<bool?>
{

View File

@@ -3,7 +3,7 @@ using System.Text;
namespace SabreTools.CommandLine.Inputs
{
/// <summary>
/// Represents a user input bounded to the range of <see cref="short"/>
/// Represents a user input bounded to the range of <see cref="short"/>
/// </summary>
public class Int16Input : UserInput<short?>
{

View File

@@ -3,7 +3,7 @@ using System.Text;
namespace SabreTools.CommandLine.Inputs
{
/// <summary>
/// Represents a user input bounded to the range of <see cref="int"/>
/// Represents a user input bounded to the range of <see cref="int"/>
/// </summary>
public class Int32Input : UserInput<int?>
{

View File

@@ -3,7 +3,7 @@ using System.Text;
namespace SabreTools.CommandLine.Inputs
{
/// <summary>
/// Represents a user input bounded to the range of <see cref="long"/>
/// Represents a user input bounded to the range of <see cref="long"/>
/// </summary>
public class Int64Input : UserInput<long?>
{

View File

@@ -3,7 +3,7 @@ using System.Text;
namespace SabreTools.CommandLine.Inputs
{
/// <summary>
/// Represents a user input bounded to the range of <see cref="sbyte"/>
/// Represents a user input bounded to the range of <see cref="sbyte"/>
/// </summary>
public class Int8Input : UserInput<sbyte?>
{

View File

@@ -3,7 +3,7 @@ using System.Text;
namespace SabreTools.CommandLine.Inputs
{
/// <summary>
/// Represents a user input bounded to the range of <see cref="ushort"/>
/// Represents a user input bounded to the range of <see cref="ushort"/>
/// </summary>
public class UInt16Input : UserInput<ushort?>
{

View File

@@ -3,7 +3,7 @@ using System.Text;
namespace SabreTools.CommandLine.Inputs
{
/// <summary>
/// Represents a user input bounded to the range of <see cref="uint"/>
/// Represents a user input bounded to the range of <see cref="uint"/>
/// </summary>
public class UInt32Input : UserInput<uint?>
{

View File

@@ -3,7 +3,7 @@ using System.Text;
namespace SabreTools.CommandLine.Inputs
{
/// <summary>
/// Represents a user input bounded to the range of <see cref="ulong"/>
/// Represents a user input bounded to the range of <see cref="ulong"/>
/// </summary>
public class UInt64Input : UserInput<ulong?>
{

View File

@@ -3,7 +3,7 @@ using System.Text;
namespace SabreTools.CommandLine.Inputs
{
/// <summary>
/// Represents a user input bounded to the range of <see cref="byte"/>
/// Represents a user input bounded to the range of <see cref="byte"/>
/// </summary>
public class UInt8Input : UserInput<byte?>
{

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Text;
using SabreTools.CommandLine.Tools;
namespace SabreTools.CommandLine.Inputs
{
@@ -41,7 +42,7 @@ namespace SabreTools.CommandLine.Inputs
/// <summary>
/// Set of children associated with this input
/// </summary>
protected readonly Dictionary<string, UserInput> Children = [];
protected internal readonly Dictionary<string, UserInput> Children = [];
#endregion
@@ -86,7 +87,7 @@ namespace SabreTools.CommandLine.Inputs
/// </summary>
public UserInput? this[UserInput subfeature]
{
get
get
{
if (!Children.TryGetValue(subfeature.Name, out var input))
return null;
@@ -135,6 +136,19 @@ namespace SabreTools.CommandLine.Inputs
return defaultValue;
}
/// <summary>
/// Get a Feature value from a named input
/// </summary>
/// <param name="key">Input name to retrieve, if possible</param>
/// <returns>The value if found, null otherwise</returns>
public Feature? GetFeature(string key)
{
if (TryGetFeature(key, out Feature? value))
return value;
return null;
}
/// <summary>
/// Get an Int8 value from a named input
/// </summary>
@@ -298,7 +312,7 @@ namespace SabreTools.CommandLine.Inputs
return true;
}
throw new ArgumentException("Feature is not a bool");
throw new ArgumentException("Input is not a bool");
}
// Check all children recursively
@@ -312,6 +326,35 @@ namespace SabreTools.CommandLine.Inputs
return false;
}
/// <summary>
/// Get a Feature value from a named input
/// </summary>
/// <param name="key">Input name to retrieve, if possible</param>
/// <param name="value">Value that was found, default value otherwise</param>
/// <returns>True if the value was found, false otherwise</returns>
public bool TryGetFeature(string key, out Feature? value)
{
// Try to check immediate children
if (Children.TryGetValue(key, out var input))
{
if (input is not Feature i)
throw new ArgumentException("Input is not a Feature");
value = i;
return true;
}
// Check all children recursively
foreach (var child in Children.Values)
{
if (child.TryGetFeature(key, out value))
return true;
}
value = null;
return false;
}
/// <summary>
/// Get an Int8 value from a named input
/// </summary>
@@ -325,7 +368,7 @@ namespace SabreTools.CommandLine.Inputs
if (Children.TryGetValue(key, out var input))
{
if (input is not Int8Input i)
throw new ArgumentException("Feature is not an sbyte");
throw new ArgumentException("Input is not an sbyte");
value = i.Value ?? defaultValue;
return true;
@@ -355,7 +398,7 @@ namespace SabreTools.CommandLine.Inputs
if (Children.TryGetValue(key, out var input))
{
if (input is not Int16Input i)
throw new ArgumentException("Feature is not a short");
throw new ArgumentException("Input is not a short");
value = i.Value ?? defaultValue;
return true;
@@ -385,7 +428,7 @@ namespace SabreTools.CommandLine.Inputs
if (Children.TryGetValue(key, out var input))
{
if (input is not Int32Input i)
throw new ArgumentException("Feature is not an int");
throw new ArgumentException("Input is not an int");
value = i.Value ?? defaultValue;
return true;
@@ -415,7 +458,7 @@ namespace SabreTools.CommandLine.Inputs
if (Children.TryGetValue(key, out var input))
{
if (input is not Int64Input l)
throw new ArgumentException("Feature is not a long");
throw new ArgumentException("Input is not a long");
value = l.Value ?? defaultValue;
return true;
@@ -445,7 +488,7 @@ namespace SabreTools.CommandLine.Inputs
if (Children.TryGetValue(key, out var input))
{
if (input is not StringInput s)
throw new ArgumentException("Feature is not a string");
throw new ArgumentException("Input is not a string");
value = s.Value ?? defaultValue;
return true;
@@ -474,7 +517,7 @@ namespace SabreTools.CommandLine.Inputs
if (Children.TryGetValue(key, out var input))
{
if (input is not StringListInput l)
throw new ArgumentException("Feature is not a list");
throw new ArgumentException("Input is not a list");
value = l.Value ?? [];
return true;
@@ -504,7 +547,7 @@ namespace SabreTools.CommandLine.Inputs
if (Children.TryGetValue(key, out var input))
{
if (input is not UInt8Input i)
throw new ArgumentException("Feature is not an byte");
throw new ArgumentException("Input is not an byte");
value = i.Value ?? defaultValue;
return true;
@@ -534,7 +577,7 @@ namespace SabreTools.CommandLine.Inputs
if (Children.TryGetValue(key, out var input))
{
if (input is not UInt16Input i)
throw new ArgumentException("Feature is not a ushort");
throw new ArgumentException("Input is not a ushort");
value = i.Value ?? defaultValue;
return true;
@@ -564,7 +607,7 @@ namespace SabreTools.CommandLine.Inputs
if (Children.TryGetValue(key, out var input))
{
if (input is not UInt32Input i)
throw new ArgumentException("Feature is not an uint");
throw new ArgumentException("Input is not an uint");
value = i.Value ?? defaultValue;
return true;
@@ -594,7 +637,7 @@ namespace SabreTools.CommandLine.Inputs
if (Children.TryGetValue(key, out var input))
{
if (input is not UInt64Input l)
throw new ArgumentException("Feature is not a ulong");
throw new ArgumentException("Input is not a ulong");
value = l.Value ?? defaultValue;
return true;
@@ -681,13 +724,45 @@ namespace SabreTools.CommandLine.Inputs
protected abstract string FormatFlags();
/// <summary>
/// Create a padding space based on the given length
/// Create roff man page entries for this input and all of its children
/// </summary>
/// <param name="spaces">Number of padding spaces to add</param>
/// <returns>String with requested number of blank spaces</returns>
private static string CreatePadding(int spaces) => spaces > 0
? string.Empty.PadRight(spaces)
: string.Empty;
/// <param name="includeVerbose">True if the detailed description should be included, false otherwise</param>
/// <returns>Roff man page lines for this input and its children</returns>
internal List<string> FormatManpage(bool includeVerbose)
{
List<string> output = [];
// Use the flags as the tag, falling back to the name when none are formatted
string tag = FormatFlags();
if (tag.Length == 0)
tag = Name;
// Tagged paragraph: bold flags followed by the description
output.Add(".TP");
output.Add(".B " + Roff.Escape(tag));
output.AddRange(Roff.FormatText(Description));
// Append the detailed description as an additional paragraph
if (includeVerbose && !string.IsNullOrEmpty(DetailedDescription))
{
output.Add(".sp");
output.AddRange(Roff.FormatText(DetailedDescription));
}
// Indent and append all children recursively
if (Children.Count > 0)
{
output.Add(".RS");
foreach (var child in Children.Values)
{
output.AddRange(child.FormatManpage(includeVerbose));
}
output.Add(".RE");
}
return output;
}
/// <summary>
/// Format the standard help output line
@@ -797,6 +872,15 @@ namespace SabreTools.CommandLine.Inputs
return outputList;
}
/// <summary>
/// Create a padding space based on the given length
/// </summary>
/// <param name="spaces">Number of padding spaces to add</param>
/// <returns>String with requested number of blank spaces</returns>
private static string CreatePadding(int spaces) => spaces > 0
? string.Empty.PadRight(spaces)
: string.Empty;
/// <summary>
/// Create formatted help text including all children
/// </summary>

View File

@@ -27,10 +27,10 @@ namespace SabreTools.CommandLine.Inputs
#region Instance Methods
/// <inheritdoc/>
public override abstract bool ProcessInput(string[] args, ref int index);
public abstract override bool ProcessInput(string[] args, ref int index);
/// <inheritdoc/>
protected override abstract string FormatFlags();
protected abstract override string FormatFlags();
#endregion
}

View File

@@ -0,0 +1,69 @@
namespace SabreTools.CommandLine
{
/// <summary>
/// Document-level metadata used when generating a man page
/// </summary>
/// <remarks>
/// These values populate the parts of a man page that cannot be
/// derived from the command-line model itself, such as the
/// <c>.TH</c> title line and the <c>NAME</c> section.
/// </remarks>
public class ManpageInfo
{
/// <summary>
/// Program name as invoked on the command line
/// </summary>
/// <remarks>
/// Used for the <c>.TH</c> title line, the <c>NAME</c> section,
/// and the <c>SYNOPSIS</c> section.
/// </remarks>
public string Name { get; set; }
/// <summary>
/// Manual section the page belongs to
/// </summary>
/// <remarks>Defaults to section 1 (user commands)</remarks>
public string Section { get; set; } = "1";
/// <summary>
/// Date used for the <c>.TH</c> title line
/// </summary>
/// <remarks>
/// When left null or empty, the field is emitted blank so that a
/// downstream packager can stamp it at build time.
/// </remarks>
public string? Date { get; set; }
/// <summary>
/// Version string used for the <c>.TH</c> title line
/// </summary>
public string? Version { get; set; }
/// <summary>
/// Manual title used for the <c>.TH</c> title line
/// </summary>
/// <remarks>Typically a value such as "User Commands"</remarks>
public string? Title { get; set; }
/// <summary>
/// Short description used for the <c>NAME</c> section
/// </summary>
/// <remarks>Should be a single printable line</remarks>
public string? Description { get; set; }
/// <summary>
/// Section heading used for the list of inputs
/// </summary>
/// <remarks>Defaults to "OPTIONS"</remarks>
public string OptionsHeading { get; set; } = "OPTIONS";
/// <summary>
/// Create a new <see cref="ManpageInfo"/> for the given program name
/// </summary>
/// <param name="name">Program name as invoked on the command line</param>
public ManpageInfo(string name)
{
Name = name;
}
}
}

View File

@@ -2,7 +2,7 @@
<PropertyGroup>
<!-- Assembly Properties -->
<TargetFrameworks>net20;net35;net40;net452;net462;net472;net48;netcoreapp3.1;net5.0;net6.0;net7.0;net8.0;net9.0;netstandard2.0;netstandard2.1</TargetFrameworks>
<TargetFrameworks>net20;net35;net40;net452;net462;net472;net48;netcoreapp3.1;net5.0;net6.0;net7.0;net8.0;net9.0;net10.0;net11.0;netstandard2.0;netstandard2.1</TargetFrameworks>
<CheckEolTargetFramework>false</CheckEolTargetFramework>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
<IncludeSymbols>true</IncludeSymbols>
@@ -11,12 +11,12 @@
<SuppressTfmSupportBuildWarnings>true</SuppressTfmSupportBuildWarnings>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Version>1.3.1</Version>
<Version>1.6.0</Version>
<!-- Package Properties -->
<Authors>Matt Nadareski</Authors>
<Description>Commandline parsing and output library</Description>
<Copyright>Copyright (c)2016-2025 Matt Nadareski</Copyright>
<Copyright>Copyright (c)2016-2026 Matt Nadareski</Copyright>
<PackageProjectUrl>https://github.com/SabreTools/</PackageProjectUrl>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://github.com/SabreTools/SabreTools.CommandLine</RepositoryUrl>

View File

@@ -0,0 +1,150 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace SabreTools.CommandLine.Tools
{
/// <summary>
/// Helpers for emitting roff-formatted man page text
/// </summary>
/// <remarks>
/// Output is restricted to the portable man(7) macro set so that the
/// same page renders without warnings under both groff and mandoc.
/// </remarks>
internal static class Roff
{
/// <summary>
/// Maximum input line length, in characters, before wrapping
/// </summary>
private const int LineWidth = 78;
/// <summary>
/// Escape a value for safe inclusion in roff output
/// </summary>
/// <param name="value">Value to escape, if any</param>
/// <returns>The escaped value, or an empty string if null or empty</returns>
public static string Escape(string? value)
{
if (string.IsNullOrEmpty(value))
return string.Empty;
// Backslashes are escaped first so that escapes introduced by
// later replacements are not themselves re-escaped
string escaped = value!.Replace("\\", "\\e");
// Render hyphens as literal ASCII minus signs instead of letting
// groff translate them to Unicode hyphens in UTF-8 output
escaped = escaped.Replace("-", "\\-");
return escaped;
}
/// <summary>
/// Escape a value for inclusion in a quoted <c>.TH</c> title field
/// </summary>
/// <param name="value">Value to escape, if any</param>
/// <returns>The escaped value, or an empty string if null or empty</returns>
/// <remarks>
/// Unlike <see cref="Escape"/>, hyphens are left intact so that the
/// date field remains parseable by mandoc and version strings render
/// verbatim. Embedded quotes are escaped so they cannot terminate the
/// surrounding field.
/// </remarks>
public static string EscapeField(string? value)
{
if (string.IsNullOrEmpty(value))
return string.Empty;
return value!.Replace("\\", "\\e")
.Replace("\"", "\\(dq");
}
/// <summary>
/// Format a block of text into wrapped, escaped roff output lines
/// </summary>
/// <param name="text">Text block to format, which may contain newlines</param>
/// <returns>Roff-safe output lines for the text block</returns>
public static List<string> FormatText(string? text)
{
List<string> output = [];
if (string.IsNullOrEmpty(text))
return output;
// Normalize line endings and split into paragraphs on blank lines
string normalized = text!.Replace("\r\n", "\n").Replace("\r", "\n");
string[] paragraphs = normalized.Split(["\n\n"], StringSplitOptions.None);
for (int i = 0; i < paragraphs.Length; i++)
{
// Collapse single newlines within a paragraph into spaces
string paragraph = paragraphs[i].Replace("\n", " ").Trim();
if (paragraph.Length == 0)
continue;
// Separate consecutive paragraphs with a blank line
if (output.Count > 0)
output.Add(".sp");
List<string> wrapped = WrapParagraph(paragraph);
output.AddRange(wrapped);
}
return output;
}
/// <summary>
/// Wrap a single paragraph into escaped roff output lines
/// </summary>
/// <param name="paragraph">Single-line paragraph text to wrap</param>
/// <returns>Escaped output lines no longer than the configured width</returns>
private static List<string> WrapParagraph(string paragraph)
{
List<string> lines = [];
string[] words = paragraph.Split(' ');
var sb = new StringBuilder();
for (int i = 0; i < words.Length; i++)
{
string word = Escape(words[i]);
if (word.Length == 0)
continue;
if (sb.Length == 0)
{
sb.Append(word);
}
else if (sb.Length + 1 + word.Length <= LineWidth)
{
sb.Append(' ');
sb.Append(word);
}
else
{
lines.Add(Protect(sb.ToString()));
sb = new StringBuilder();
sb.Append(word);
}
}
if (sb.Length > 0)
lines.Add(Protect(sb.ToString()));
return lines;
}
/// <summary>
/// Protect a text line from being parsed as a roff control line
/// </summary>
/// <param name="line">Line to protect</param>
/// <returns>The line, prefixed with a zero-width escape when needed</returns>
private static string Protect(string line)
{
// A line beginning with a control character would be treated as a
// request; a leading zero-width escape forces it to be text
if (line.Length > 0 && (line[0] == '.' || line[0] == '\''))
return $"\\&{line}";
return line;
}
}
}

View File

@@ -1,7 +1,7 @@
#! /bin/bash
# This batch file assumes the following:
# - .NET 9.0 (or newer) SDK is installed and in PATH
# - .NET 11.0 (or newer) SDK is installed and in PATH
#
# If any of these are not satisfied, the operation may fail
# in an unpredictable way and result in an incomplete output.

View File

@@ -1,5 +1,5 @@
# This batch file assumes the following:
# - .NET 9.0 (or newer) SDK is installed and in PATH
# - .NET 11.0 (or newer) SDK is installed and in PATH
#
# If any of these are not satisfied, the operation may fail
# in an unpredictable way and result in an incomplete output.