feat: initial project commit
Some checks failed
default / default (8.0) (push) Failing after 26s

This commit is contained in:
Louis Seubert 2024-04-27 20:15:05 +02:00
commit b54acec2f2
Signed by: louis9902
GPG key ID: 4B9DB28F826553BD
66 changed files with 5135 additions and 0 deletions

447
.editorconfig Normal file
View file

@ -0,0 +1,447 @@
root = true
[*]
indent_style = tab
indent_size = 4
tab_width = 4
end_of_line = lf
insert_final_newline = false
trim_trailing_whitespace = true
[.forgejo/workflows/*.yml]
indent_size = 2
indent_style = space
[*.{xml,csproj,props,targets}]
indent_size = 2
indent_style = space
[nuget.config]
indent_size = 2
indent_style = space
[*.{cs,vb}] #### .NET Coding Conventions ####
# Organize usings
dotnet_separate_import_directive_groups = true
dotnet_sort_system_directives_first = true
file_header_template = unset
# this. and Me. preferences
dotnet_style_qualification_for_event = false:silent
dotnet_style_qualification_for_field = false:silent
dotnet_style_qualification_for_method = false:silent
dotnet_style_qualification_for_property = false:silent
# Language keywords vs BCL types preferences
dotnet_style_predefined_type_for_locals_parameters_members = true:silent
dotnet_style_predefined_type_for_member_access = true:silent
# Parentheses preferences
dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent
dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent
dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent
dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent
# Modifier preferences
dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent
# Expression-level preferences
dotnet_style_coalesce_expression = true:suggestion
dotnet_style_collection_initializer = true:suggestion
dotnet_style_explicit_tuple_names = true:suggestion
dotnet_style_null_propagation = true:suggestion
dotnet_style_object_initializer = true:suggestion
dotnet_style_operator_placement_when_wrapping = beginning_of_line
dotnet_style_prefer_auto_properties = true:suggestion
dotnet_style_prefer_compound_assignment = true:suggestion
dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion
dotnet_style_prefer_conditional_expression_over_return = true:suggestion
dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
dotnet_style_prefer_inferred_tuple_names = true:suggestion
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
dotnet_style_prefer_simplified_boolean_expressions = true:suggestion
dotnet_style_prefer_simplified_interpolation = true:suggestion
dotnet_style_namespace_match_folder = false # resharper
# Field preferences
dotnet_style_readonly_field = true:warning
# Parameter preferences
dotnet_code_quality_unused_parameters = all:suggestion
# Suppression preferences
dotnet_remove_unnecessary_suppression_exclusions = none
# ReSharper preferences
resharper_check_namespace_highlighting = none
[*.cs] #### C# Coding Conventions ####
# var preferences
csharp_style_var_for_built_in_types = true:suggestion
csharp_style_var_when_type_is_apparent = true:suggestion
csharp_style_var_elsewhere = true:suggestion
# Expression-bodied members
csharp_style_expression_bodied_accessors = true:silent
csharp_style_expression_bodied_constructors = false:silent
csharp_style_expression_bodied_indexers = true:silent
csharp_style_expression_bodied_lambdas = true:suggestion
csharp_style_expression_bodied_local_functions = false:silent
csharp_style_expression_bodied_methods = false:silent
csharp_style_expression_bodied_operators = false:silent
csharp_style_expression_bodied_properties = true:silent
# Pattern matching preferences
csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
csharp_style_prefer_not_pattern = true:suggestion
csharp_style_prefer_pattern_matching = true:silent
csharp_style_prefer_switch_expression = true:suggestion
# Null-checking preferences
csharp_style_conditional_delegate_call = true:suggestion
# Modifier preferences
csharp_prefer_static_local_function = true:warning
csharp_preferred_modifier_order = public, private, protected, internal, static, extern, new, virtual, abstract, sealed, override, readonly, unsafe, volatile, async:silent
# Code-block preferences
csharp_prefer_braces = true:silent
csharp_prefer_simple_using_statement = true:suggestion
# Expression-level preferences
csharp_prefer_simple_default_expression = true:suggestion
csharp_style_deconstructed_variable_declaration = true:suggestion
csharp_style_inlined_variable_declaration = true:suggestion
csharp_style_pattern_local_over_anonymous_function = true:suggestion
csharp_style_prefer_index_operator = true:suggestion
csharp_style_prefer_range_operator = true:suggestion
csharp_style_throw_expression = true:suggestion
csharp_style_unused_value_assignment_preference = discard_variable:suggestion
csharp_style_unused_value_expression_statement_preference = discard_variable:silent
# 'using' directive preferences
csharp_using_directive_placement = outside_namespace:silent
# 'namespace' preferences
csharp_style_namespace_declarations = file_scoped:warning
[*.cs] #### C# Formatting Rules ####
# New line preferences
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
# Indentation preferences
csharp_indent_block_contents = true
csharp_indent_braces = false
csharp_indent_case_contents = true
csharp_indent_case_contents_when_block = true
csharp_indent_labels = one_less_than_current
csharp_indent_switch_labels = true
# Space preferences
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_name_and_open_parenthesis = false
csharp_space_between_method_declaration_parameter_list_parentheses = false
csharp_space_between_parentheses = false
csharp_space_between_square_brackets = false
# Wrapping preferences
csharp_preserve_single_line_blocks = true
csharp_preserve_single_line_statements = true
[*.{cs,vb}] #### .NET Naming styles ####
dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.symbols = types_and_namespaces
dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.style = pascalcase
dotnet_naming_symbols.types_and_namespaces.applicable_kinds = namespace, class, struct, interface, enum
dotnet_naming_symbols.types_and_namespaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.types_and_namespaces.required_modifiers =
dotnet_naming_rule.interfaces_should_be_ipascalcase.severity = suggestion
dotnet_naming_rule.interfaces_should_be_ipascalcase.symbols = interfaces
dotnet_naming_rule.interfaces_should_be_ipascalcase.style = ipascalcase
dotnet_naming_symbols.interfaces.applicable_kinds = interface
dotnet_naming_symbols.interfaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.interfaces.required_modifiers =
dotnet_naming_rule.enums_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.enums_should_be_pascalcase.symbols = enums
dotnet_naming_rule.enums_should_be_pascalcase.style = pascalcase
dotnet_naming_symbols.enums.applicable_kinds = enum
dotnet_naming_symbols.enums.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.enums.required_modifiers =
dotnet_naming_rule.type_parameters_should_be_tpascalcase.severity = suggestion
dotnet_naming_rule.type_parameters_should_be_tpascalcase.symbols = type_parameters
dotnet_naming_rule.type_parameters_should_be_tpascalcase.style = tpascalcase
dotnet_naming_symbols.type_parameters.applicable_kinds = namespace
dotnet_naming_symbols.type_parameters.applicable_accessibilities = *
dotnet_naming_symbols.type_parameters.required_modifiers =
dotnet_naming_rule.methods_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.methods_should_be_pascalcase.symbols = methods
dotnet_naming_rule.methods_should_be_pascalcase.style = pascalcase
dotnet_naming_symbols.methods.applicable_kinds = method
dotnet_naming_symbols.methods.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.methods.required_modifiers =
dotnet_naming_rule.async_methods_end_in_async.severity = suggestion
dotnet_naming_rule.async_methods_end_in_async.symbols = any_async_methods
dotnet_naming_rule.async_methods_end_in_async.style = end_in_async
dotnet_naming_symbols.any_async_methods.applicable_kinds = method
dotnet_naming_symbols.any_async_methods.applicable_accessibilities = *
dotnet_naming_symbols.any_async_methods.required_modifiers = async
dotnet_naming_rule.parameters_should_be_camelcase.severity = suggestion
dotnet_naming_rule.parameters_should_be_camelcase.symbols = parameters
dotnet_naming_rule.parameters_should_be_camelcase.style = camelcase
dotnet_naming_symbols.parameters.applicable_kinds = parameter
dotnet_naming_symbols.parameters.applicable_accessibilities = *
dotnet_naming_symbols.parameters.required_modifiers =
dotnet_naming_rule.properties_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.properties_should_be_pascalcase.symbols = properties
dotnet_naming_rule.properties_should_be_pascalcase.style = pascalcase
dotnet_naming_symbols.properties.applicable_kinds = property
dotnet_naming_symbols.properties.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.properties.required_modifiers =
dotnet_naming_rule.events_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.events_should_be_pascalcase.symbols = events
dotnet_naming_rule.events_should_be_pascalcase.style = pascalcase
dotnet_naming_symbols.events.applicable_kinds = event
dotnet_naming_symbols.events.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.events.required_modifiers =
# local
dotnet_naming_rule.local_variables_should_be_camelcase.severity = suggestion
dotnet_naming_rule.local_variables_should_be_camelcase.symbols = local_variables
dotnet_naming_rule.local_variables_should_be_camelcase.style = camelcase
dotnet_naming_symbols.local_variables.applicable_kinds = local
dotnet_naming_symbols.local_variables.applicable_accessibilities = local
dotnet_naming_symbols.local_variables.required_modifiers =
dotnet_naming_rule.local_functions_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.local_functions_should_be_pascalcase.symbols = local_functions
dotnet_naming_rule.local_functions_should_be_pascalcase.style = pascalcase
dotnet_naming_symbols.local_functions.applicable_kinds = local_function
dotnet_naming_symbols.local_functions.applicable_accessibilities = *
dotnet_naming_symbols.local_functions.required_modifiers =
dotnet_naming_rule.local_constants_should_be_camelcase.severity = suggestion
dotnet_naming_rule.local_constants_should_be_camelcase.symbols = local_constants
dotnet_naming_rule.local_constants_should_be_camelcase.style = camelcase
dotnet_naming_symbols.local_constants.applicable_kinds = local
dotnet_naming_symbols.local_constants.applicable_accessibilities = local
dotnet_naming_symbols.local_constants.required_modifiers = const
# private
dotnet_naming_rule.private_fields_should_be__camelcase.severity = suggestion
dotnet_naming_rule.private_fields_should_be__camelcase.symbols = private_fields
dotnet_naming_rule.private_fields_should_be__camelcase.style = _camelcase
dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
dotnet_naming_symbols.private_fields.required_modifiers =
dotnet_naming_rule.private_static_fields_should_be_s_camelcase.severity = suggestion
dotnet_naming_rule.private_static_fields_should_be_s_camelcase.symbols = private_static_fields
dotnet_naming_rule.private_static_fields_should_be_s_camelcase.style = s_camelcase
dotnet_naming_symbols.private_static_fields.applicable_kinds = field
dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
dotnet_naming_symbols.private_static_fields.required_modifiers = static
dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.symbols = private_static_readonly_fields
dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.style = pascalcase
dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field
dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = readonly, static
dotnet_naming_rule.private_constant_fields_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.private_constant_fields_should_be_pascalcase.symbols = private_constant_fields
dotnet_naming_rule.private_constant_fields_should_be_pascalcase.style = pascalcase
dotnet_naming_symbols.private_constant_fields.applicable_kinds = field
dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
dotnet_naming_symbols.private_constant_fields.required_modifiers = const
# public
dotnet_naming_rule.public_fields_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.public_fields_should_be_pascalcase.symbols = public_fields
dotnet_naming_rule.public_fields_should_be_pascalcase.style = pascalcase
dotnet_naming_symbols.public_fields.applicable_kinds = field
dotnet_naming_symbols.public_fields.applicable_accessibilities = public, internal
dotnet_naming_symbols.public_fields.required_modifiers =
dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.symbols = public_static_readonly_fields
dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.style = pascalcase
dotnet_naming_symbols.public_static_readonly_fields.applicable_kinds = field
dotnet_naming_symbols.public_static_readonly_fields.applicable_accessibilities = public, internal
dotnet_naming_symbols.public_static_readonly_fields.required_modifiers = readonly, static
dotnet_naming_rule.public_constant_fields_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.public_constant_fields_should_be_pascalcase.symbols = public_constant_fields
dotnet_naming_rule.public_constant_fields_should_be_pascalcase.style = pascalcase
dotnet_naming_symbols.public_constant_fields.applicable_kinds = field
dotnet_naming_symbols.public_constant_fields.applicable_accessibilities = public, internal
dotnet_naming_symbols.public_constant_fields.required_modifiers = const
# others
dotnet_naming_rule.non_field_members_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.non_field_members_should_be_pascalcase.symbols = non_field_members
dotnet_naming_rule.non_field_members_should_be_pascalcase.style = pascalcase
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.non_field_members.required_modifiers =
# Naming styles
dotnet_naming_style.pascalcase.required_prefix =
dotnet_naming_style.pascalcase.required_suffix =
dotnet_naming_style.pascalcase.word_separator =
dotnet_naming_style.pascalcase.capitalization = pascal_case
dotnet_naming_style.ipascalcase.required_prefix = I
dotnet_naming_style.ipascalcase.required_suffix =
dotnet_naming_style.ipascalcase.word_separator =
dotnet_naming_style.ipascalcase.capitalization = pascal_case
dotnet_naming_style.tpascalcase.required_prefix = T
dotnet_naming_style.tpascalcase.required_suffix =
dotnet_naming_style.tpascalcase.word_separator =
dotnet_naming_style.tpascalcase.capitalization = pascal_case
dotnet_naming_style._camelcase.required_prefix = _
dotnet_naming_style._camelcase.required_suffix =
dotnet_naming_style._camelcase.word_separator =
dotnet_naming_style._camelcase.capitalization = camel_case
dotnet_naming_style.camelcase.required_prefix =
dotnet_naming_style.camelcase.required_suffix =
dotnet_naming_style.camelcase.word_separator =
dotnet_naming_style.camelcase.capitalization = camel_case
dotnet_naming_style.s_camelcase.required_prefix = s_
dotnet_naming_style.s_camelcase.required_suffix =
dotnet_naming_style.s_camelcase.word_separator =
dotnet_naming_style.s_camelcase.capitalization = camel_case
dotnet_naming_style.end_in_async.required_prefix =
dotnet_naming_style.end_in_async.required_suffix = Async
dotnet_naming_style.end_in_async.capitalization = pascal_case
dotnet_naming_style.end_in_async.word_separator =
[*.{cs.vb}]
dotnet_diagnostic.IDE0055.severity = error
# IDE0051: Remove unused private member
dotnet_diagnostic.IDE0051.severity = error
# IDE0052: Remove unread private member
dotnet_diagnostic.IDE0052.severity = error
# IDE0064: Make struct fields writable
dotnet_diagnostic.IDE0064.severity = error
dotnet_analyzer_diagnostic.severity = error
# CS1591: Missing XML comment for publicly visible type or member
dotnet_diagnostic.CS1591.severity = suggestion
# CA1018: Mark attributes with AttributeUsageAttribute
dotnet_diagnostic.CA1018.severity = error
# CA1304: Specify CultureInfo
dotnet_diagnostic.CA1304.severity = warning
# CA1802: Use literals where appropriate
dotnet_diagnostic.CA1802.severity = warning
# CA1813: Avoid unsealed attributes
dotnet_diagnostic.CA1813.severity = error
# CA1815: Override equals and operator equals on value types
dotnet_diagnostic.CA1815.severity = warning
# CA1820: Test for empty strings using string length
dotnet_diagnostic.CA1820.severity = warning
# CA1821: Remove empty finalizers
dotnet_diagnostic.CA1821.severity = warning
# CA1822: Mark members as static
dotnet_diagnostic.CA1822.severity = suggestion
# CA1823: Avoid unused private fields
dotnet_diagnostic.CA1823.severity = warning
dotnet_code_quality.CA1822.api_surface = private, internal
# CA1825: Avoid zero-length array allocations
dotnet_diagnostic.CA1825.severity = warning
# CA1826: Use property instead of Linq Enumerable method
dotnet_diagnostic.CA1826.severity = suggestion
# CA1827: Do not use Count/LongCount when Any can be used
dotnet_diagnostic.CA1827.severity = warning
# CA1828: Do not use CountAsync/LongCountAsync when AnyAsync can be used
dotnet_diagnostic.CA1828.severity = warning
# CA1829: Use Length/Count property instead of Enumerable.Count method
dotnet_diagnostic.CA1829.severity = warning
#CA1847: Use string.Contains(char) instead of string.Contains(string) with single characters
dotnet_diagnostic.CA1847.severity = warning
#CA1854: Prefer the IDictionary.TryGetValue(TKey, out TValue) method
dotnet_diagnostic.CA1854.severity = warning
#CA2211:Non-constant fields should not be visible
dotnet_diagnostic.CA2211.severity = error

View file

@ -0,0 +1,15 @@
# workflows
## default.yml
The default workflow which is ran on every `push` or `pull_request` which will
build and package the project. This will also run the tests as part of the
workflow to ensure the code actually behaves like it should.
When the workflow runs on the `develop` branch of the repository, then an alpha
build is released to the registry.
## release.yml
The release workflow just build the current repository ref and publishes the
artifacts to the registry.

View file

@ -0,0 +1,37 @@
name: default
on:
push:
branches: ["main", "develop"]
paths-ignore:
- "doc/**"
- "*.md"
pull_request:
branches: ["main", "develop"]
paths-ignore:
- "doc/**"
- "*.md"
jobs:
default:
runs-on: debian-latest
strategy:
matrix:
dotnet-version: ["8.0"]
container: mcr.microsoft.com/dotnet/sdk:${{ matrix.dotnet-version }}
steps:
- name: checkout
uses: https://git.geekeey.de/actions/checkout@1
- name: nuget login
run: |
# This token is readonly and can only be used for restore
dotnet nuget update source geekeey --store-password-in-clear-text \
--username "${{ github.actor }}" --password "${{ github.token }}"
- name: dotnet pack
run: |
dotnet pack -p:ContinuousIntegrationBuild=true
- name: dotnet test
run: |
dotnet test

View file

@ -0,0 +1,24 @@
name: release
on:
push:
tags: ["[0-9]+.[0-9]+.[0-9]+"]
jobs:
release:
runs-on: debian-latest
strategy:
matrix:
dotnet-version: ["8.0"]
container: mcr.microsoft.com/dotnet/sdk:${{ matrix.dotnet-version }}
steps:
- uses: https://git.geekeey.de/actions/checkout@1
- name: nuget login
run: |
# This token is readonly and can only be used for restore
dotnet nuget update source geekeey --store-password-in-clear-text \
--username "${{ github.actor }}" --password "${{ github.token }}"
- name: dotnet pack
run: |
dotnet pack -p:ContinuousIntegrationBuild=true

478
.gitignore vendored Normal file
View file

@ -0,0 +1,478 @@
## 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/main/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# 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
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET
project.lock.json
project.fragment.lock.json
artifacts/
# Tye
.tye/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.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
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# 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
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
!**/src/packages/*
# 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
*.appxbundle
*.appxupload
# 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
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# 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 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# 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/
# CodeRush personal settings
.cr/personal
# 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/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
##
## Visual studio for Mac
##
# globs
Makefile.in
*.userprefs
*.usertasks
config.make
config.status
aclocal.m4
install-sh
autom4te.cache/
*.tar.gz
tarballs/
test-results/
# Mac bundle stuff
*.dmg
*.app
# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore
# Windows thumbnail cache files
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk

27
Directory.Build.props Normal file
View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<Project>
<PropertyGroup>
<UseArtifactsOutput>true</UseArtifactsOutput>
</PropertyGroup>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<VersionPrefix>1.0.0</VersionPrefix>
<!--<VersionSuffix></VersionSuffix>-->
</PropertyGroup>
<PropertyGroup>
<AssemblyName>Geekeey.Extensions.$(MSBuildProjectName)</AssemblyName>
<RootNamespace>Geekeey.Extensions.$(MSBuildProjectName)</RootNamespace>
</PropertyGroup>
<PropertyGroup>
<Authors>The Geekeey Team</Authors>
<Description>A simple and lightweight way to interact with other processes from C#.</Description>
<PackageTags>geekeey utility process</PackageTags>
<PackageReadmeFile>package-readme.md</PackageReadmeFile>
</PropertyGroup>
</Project>

3
Directory.Build.targets Normal file
View file

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<Project>
</Project>

15
Directory.Packages.props Normal file
View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.7.1" />
<PackageVersion Include="NUnit" Version="3.14.0" />
<PackageVersion Include="NUnit3TestAdapter" Version="4.5.0" />
<PackageVersion Include="NUnit.Analyzers" Version="4.1.0" />
<PackageVersion Include="coverlet.collector" Version="6.0.1" />
<PackageVersion Include="Spectre.Console" Version="0.49.1" />
<PackageVersion Include="Spectre.Console.Cli" Version="0.49.1" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,42 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Process", "src\Process\Process.csproj", "{0B246E7A-565E-45E7-84C2-37A43C0982A3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Process.Tests", "src\Process.Tests\Process.Tests.csproj", "{C0919570-F420-49F5-A8B4-B5DF16A8EC05}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Process.Tests.Dummy", "src\Process.Tests.Dummy\Process.Tests.Dummy.csproj", "{8E6F465E-3FEE-4789-A750-9FED80CCCB8E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Process.Win.Notify", "src\Process.Win.Notify\Process.Win.Notify.csproj", "{4D83262D-E5A9-441E-854A-41CA0D7EBD11}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{0B246E7A-565E-45E7-84C2-37A43C0982A3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0B246E7A-565E-45E7-84C2-37A43C0982A3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0B246E7A-565E-45E7-84C2-37A43C0982A3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0B246E7A-565E-45E7-84C2-37A43C0982A3}.Release|Any CPU.Build.0 = Release|Any CPU
{C0919570-F420-49F5-A8B4-B5DF16A8EC05}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C0919570-F420-49F5-A8B4-B5DF16A8EC05}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C0919570-F420-49F5-A8B4-B5DF16A8EC05}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C0919570-F420-49F5-A8B4-B5DF16A8EC05}.Release|Any CPU.Build.0 = Release|Any CPU
{8E6F465E-3FEE-4789-A750-9FED80CCCB8E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8E6F465E-3FEE-4789-A750-9FED80CCCB8E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8E6F465E-3FEE-4789-A750-9FED80CCCB8E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8E6F465E-3FEE-4789-A750-9FED80CCCB8E}.Release|Any CPU.Build.0 = Release|Any CPU
{4D83262D-E5A9-441E-854A-41CA0D7EBD11}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4D83262D-E5A9-441E-854A-41CA0D7EBD11}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4D83262D-E5A9-441E-854A-41CA0D7EBD11}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4D83262D-E5A9-441E-854A-41CA0D7EBD11}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
EndGlobalSection
EndGlobal

287
LICENSE.md Normal file
View file

@ -0,0 +1,287 @@
EUROPEAN UNION PUBLIC LICENCE v. 1.2
EUPL © the European Union 2007, 2016
This European Union Public Licence (the EUPL) applies to the Work (as defined
below) which is provided under the terms of this Licence. Any use of the Work,
other than as authorised under this Licence is prohibited (to the extent such
use is covered by a right of the copyright holder of the Work).
The Work is provided under the terms of this Licence when the Licensor (as
defined below) has placed the following notice immediately following the
copyright notice for the Work:
Licensed under the EUPL
or has expressed by any other means his willingness to license under the EUPL.
1. Definitions
In this Licence, the following terms have the following meaning:
- The Licence: this Licence.
- The Original Work: the work or software distributed or communicated by the
Licensor under this Licence, available as Source Code and also as Executable
Code as the case may be.
- Derivative Works: the works or software that could be created by the
Licensee, based upon the Original Work or modifications thereof. This Licence
does not define the extent of modification or dependence on the Original Work
required in order to classify a work as a Derivative Work; this extent is
determined by copyright law applicable in the country mentioned in Article 15.
- The Work: the Original Work or its Derivative Works.
- The Source Code: the human-readable form of the Work which is the most
convenient for people to study and modify.
- The Executable Code: any code which has generally been compiled and which is
meant to be interpreted by a computer as a program.
- The Licensor: the natural or legal person that distributes or communicates
the Work under the Licence.
- Contributor(s): any natural or legal person who modifies the Work under the
Licence, or otherwise contributes to the creation of a Derivative Work.
- The Licensee or You: any natural or legal person who makes any usage of
the Work under the terms of the Licence.
- Distribution or Communication: any act of selling, giving, lending,
renting, distributing, communicating, transmitting, or otherwise making
available, online or offline, copies of the Work or providing access to its
essential functionalities at the disposal of any other natural or legal
person.
2. Scope of the rights granted by the Licence
The Licensor hereby grants You a worldwide, royalty-free, non-exclusive,
sublicensable licence to do the following, for the duration of copyright vested
in the Original Work:
- use the Work in any circumstance and for all usage,
- reproduce the Work,
- modify the Work, and make Derivative Works based upon the Work,
- communicate to the public, including the right to make available or display
the Work or copies thereof to the public and perform publicly, as the case may
be, the Work,
- distribute the Work or copies thereof,
- lend and rent the Work or copies thereof,
- sublicense rights in the Work or copies thereof.
Those rights can be exercised on any media, supports and formats, whether now
known or later invented, as far as the applicable law permits so.
In the countries where moral rights apply, the Licensor waives his right to
exercise his moral right to the extent allowed by law in order to make effective
the licence of the economic rights here above listed.
The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to
any patents held by the Licensor, to the extent necessary to make use of the
rights granted on the Work under this Licence.
3. Communication of the Source Code
The Licensor may provide the Work either in its Source Code form, or as
Executable Code. If the Work is provided as Executable Code, the Licensor
provides in addition a machine-readable copy of the Source Code of the Work
along with each copy of the Work that the Licensor distributes or indicates, in
a notice following the copyright notice attached to the Work, a repository where
the Source Code is easily and freely accessible for as long as the Licensor
continues to distribute or communicate the Work.
4. Limitations on copyright
Nothing in this Licence is intended to deprive the Licensee of the benefits from
any exception or limitation to the exclusive rights of the rights owners in the
Work, of the exhaustion of those rights or of other applicable limitations
thereto.
5. Obligations of the Licensee
The grant of the rights mentioned above is subject to some restrictions and
obligations imposed on the Licensee. Those obligations are the following:
Attribution right: The Licensee shall keep intact all copyright, patent or
trademarks notices and all notices that refer to the Licence and to the
disclaimer of warranties. The Licensee must include a copy of such notices and a
copy of the Licence with every copy of the Work he/she distributes or
communicates. The Licensee must cause any Derivative Work to carry prominent
notices stating that the Work has been modified and the date of modification.
Copyleft clause: If the Licensee distributes or communicates copies of the
Original Works or Derivative Works, this Distribution or Communication will be
done under the terms of this Licence or of a later version of this Licence
unless the Original Work is expressly distributed only under this version of the
Licence — for example by communicating EUPL v. 1.2 only. The Licensee
(becoming Licensor) cannot offer or impose any additional terms or conditions on
the Work or Derivative Work that alter or restrict the terms of the Licence.
Compatibility clause: If the Licensee Distributes or Communicates Derivative
Works or copies thereof based upon both the Work and another work licensed under
a Compatible Licence, this Distribution or Communication can be done under the
terms of this Compatible Licence. For the sake of this clause, Compatible
Licence refers to the licences listed in the appendix attached to this Licence.
Should the Licensee's obligations under the Compatible Licence conflict with
his/her obligations under this Licence, the obligations of the Compatible
Licence shall prevail.
Provision of Source Code: When distributing or communicating copies of the Work,
the Licensee will provide a machine-readable copy of the Source Code or indicate
a repository where this Source will be easily and freely available for as long
as the Licensee continues to distribute or communicate the Work.
Legal Protection: This Licence does not grant permission to use the trade names,
trademarks, service marks, or names of the Licensor, except as required for
reasonable and customary use in describing the origin of the Work and
reproducing the content of the copyright notice.
6. Chain of Authorship
The original Licensor warrants that the copyright in the Original Work granted
hereunder is owned by him/her or licensed to him/her and that he/she has the
power and authority to grant the Licence.
Each Contributor warrants that the copyright in the modifications he/she brings
to the Work are owned by him/her or licensed to him/her and that he/she has the
power and authority to grant the Licence.
Each time You accept the Licence, the original Licensor and subsequent
Contributors grant You a licence to their contributions to the Work, under the
terms of this Licence.
7. Disclaimer of Warranty
The Work is a work in progress, which is continuously improved by numerous
Contributors. It is not a finished work and may therefore contain defects or
bugs inherent to this type of development.
For the above reason, the Work is provided under the Licence on an as is basis
and without warranties of any kind concerning the Work, including without
limitation merchantability, fitness for a particular purpose, absence of defects
or errors, accuracy, non-infringement of intellectual property rights other than
copyright as stated in Article 6 of this Licence.
This disclaimer of warranty is an essential part of the Licence and a condition
for the grant of any rights to the Work.
8. Disclaimer of Liability
Except in the cases of wilful misconduct or damages directly caused to natural
persons, the Licensor will in no event be liable for any direct or indirect,
material or moral, damages of any kind, arising out of the Licence or of the use
of the Work, including without limitation, damages for loss of goodwill, work
stoppage, computer failure or malfunction, loss of data or any commercial
damage, even if the Licensor has been advised of the possibility of such damage.
However, the Licensor will be liable under statutory product liability laws as
far such laws apply to the Work.
9. Additional agreements
While distributing the Work, You may choose to conclude an additional agreement,
defining obligations or services consistent with this Licence. However, if
accepting obligations, You may act only on your own behalf and on your sole
responsibility, not on behalf of the original Licensor or any other Contributor,
and only if You agree to indemnify, defend, and hold each Contributor harmless
for any liability incurred by, or claims asserted against such Contributor by
the fact You have accepted any warranty or additional liability.
10. Acceptance of the Licence
The provisions of this Licence can be accepted by clicking on an icon I agree
placed under the bottom of a window displaying the text of this Licence or by
affirming consent in any other similar way, in accordance with the rules of
applicable law. Clicking on that icon indicates your clear and irrevocable
acceptance of this Licence and all of its terms and conditions.
Similarly, you irrevocably accept this Licence and all of its terms and
conditions by exercising any rights granted to You by Article 2 of this Licence,
such as the use of the Work, the creation by You of a Derivative Work or the
Distribution or Communication by You of the Work or copies thereof.
11. Information to the public
In case of any Distribution or Communication of the Work by means of electronic
communication by You (for example, by offering to download the Work from a
remote location) the distribution channel or media (for example, a website) must
at least provide to the public the information requested by the applicable law
regarding the Licensor, the Licence and the way it may be accessible, concluded,
stored and reproduced by the Licensee.
12. Termination of the Licence
The Licence and the rights granted hereunder will terminate automatically upon
any breach by the Licensee of the terms of the Licence.
Such a termination will not terminate the licences of any person who has
received the Work from the Licensee under the Licence, provided such persons
remain in full compliance with the Licence.
13. Miscellaneous
Without prejudice of Article 9 above, the Licence represents the complete
agreement between the Parties as to the Work.
If any provision of the Licence is invalid or unenforceable under applicable
law, this will not affect the validity or enforceability of the Licence as a
whole. Such provision will be construed or reformed so as necessary to make it
valid and enforceable.
The European Commission may publish other linguistic versions or new versions of
this Licence or updated versions of the Appendix, so far this is required and
reasonable, without reducing the scope of the rights granted by the Licence. New
versions of the Licence will be published with a unique version number.
All linguistic versions of this Licence, approved by the European Commission,
have identical value. Parties can take advantage of the linguistic version of
their choice.
14. Jurisdiction
Without prejudice to specific agreement between parties,
- any litigation resulting from the interpretation of this License, arising
between the European Union institutions, bodies, offices or agencies, as a
Licensor, and any Licensee, will be subject to the jurisdiction of the Court
of Justice of the European Union, as laid down in article 272 of the Treaty on
the Functioning of the European Union,
- any litigation arising between other parties and resulting from the
interpretation of this License, will be subject to the exclusive jurisdiction
of the competent court where the Licensor resides or conducts its primary
business.
15. Applicable Law
Without prejudice to specific agreement between parties,
- this Licence shall be governed by the law of the European Union Member State
where the Licensor has his seat, resides or has his registered office,
- this licence shall be governed by Belgian law if the Licensor has no seat,
residence or registered office inside a European Union Member State.
Appendix
Compatible Licences according to Article 5 EUPL are:
- GNU General Public License (GPL) v. 2, v. 3
- GNU Affero General Public License (AGPL) v. 3
- Open Software License (OSL) v. 2.1, v. 3.0
- Eclipse Public License (EPL) v. 1.0
- CeCILL v. 2.0, v. 2.1
- Mozilla Public Licence (MPL) v. 2
- GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3
- Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for
works other than software
- European Union Public Licence (EUPL) v. 1.1, v. 1.2
- Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong
Reciprocity (LiLiQ-R+).
The European Commission may update this Appendix to later versions of the above
licences without producing a new version of the EUPL, as long as they provide
the rights granted in Article 2 of this Licence and protect the covered Source
Code from exclusive appropriation.
All other changes or additions to this Appendix require the production of a new
EUPL version.

9
global.json Normal file
View file

@ -0,0 +1,9 @@
{
"sdk": {
"version": "8.0.0",
"rollForward": "latestMajor",
"allowPrerelease": true
},
"msbuild-sdks": {
}
}

19
nuget.config Normal file
View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<config>
<add key="defaultPushSource" value="geekeey" />
</config>
<packageSources>
<clear />
<add key="nuget" value="https://api.nuget.org/v3/index.json" />
<add key="geekeey" value="https://git.geekeey.de/api/packages/geekeey/nuget/index.json" />
</packageSources>
<packageSourceMapping>
<packageSource key="nuget">
<package pattern="*" />
</packageSource>
<packageSource key="geekeey">
<package pattern="Geekeey.*" />
</packageSource>
</packageSourceMapping>
</configuration>

View file

@ -0,0 +1,12 @@
using Spectre.Console.Cli;
namespace Geekeey.Extensions.Process.Tests.Dummy;
internal abstract class AsyncOutputCommand<T> : AsyncCommand<T> where T : OutputCommandSettings
{
}
internal abstract class OutputCommandSettings : CommandSettings
{
[CommandOption("--target")] public OutputTarget Target { get; init; } = OutputTarget.StdOut;
}

View file

@ -0,0 +1,24 @@
using Spectre.Console.Cli;
namespace Geekeey.Extensions.Process.Tests.Dummy.Commands;
internal sealed class EchoCommand : AsyncOutputCommand<EchoCommand.Settings>
{
public sealed class Settings : OutputCommandSettings
{
[CommandOption("--separator <char>")] public string Separator { get; init; } = " ";
[CommandArgument(0, "[line]")] public string[] Items { get; init; } = [];
}
public override async Task<int> ExecuteAsync(CommandContext context, Settings settings)
{
using var tty = Terminal.Connect();
foreach (var writer in tty.GetWriters(settings.Target))
{
await writer.WriteLineAsync(string.Join(settings.Separator, settings.Items));
}
return 0;
}
}

View file

@ -0,0 +1,38 @@
using System.Buffers;
using Spectre.Console.Cli;
namespace Geekeey.Extensions.Process.Tests.Dummy.Commands;
internal sealed class EchoStdinCommand : AsyncOutputCommand<EchoStdinCommand.Settings>
{
public sealed class Settings : OutputCommandSettings
{
[CommandOption("--length")] public long Length { get; init; } = long.MaxValue;
}
public override async Task<int> ExecuteAsync(CommandContext context, Settings settings)
{
using var tty = Terminal.Connect();
using var buffer = MemoryPool<byte>.Shared.Rent(81920);
var count = 0L;
while (count < settings.Length)
{
var bytesWanted = (int)Math.Min(buffer.Memory.Length, settings.Length - count);
var bytesRead = await tty.Stdin.BaseStream.ReadAsync(buffer.Memory[..bytesWanted]);
if (bytesRead <= 0)
break;
foreach (var writer in tty.GetWriters(settings.Target))
{
await writer.BaseStream.WriteAsync(buffer.Memory[..bytesRead]);
}
count += bytesRead;
}
return 0;
}
}

View file

@ -0,0 +1,28 @@
using Spectre.Console.Cli;
namespace Geekeey.Extensions.Process.Tests.Dummy.Commands;
internal sealed class EnvironmentCommand : AsyncOutputCommand<EnvironmentCommand.Settings>
{
public sealed class Settings : OutputCommandSettings
{
[CommandArgument(0, "<ARGUMENT>")] public string[] Variables { get; init; } = [];
}
public override async Task<int> ExecuteAsync(CommandContext context, Settings settings)
{
using var tty = Terminal.Connect();
foreach (var name in settings.Variables)
{
var value = Environment.GetEnvironmentVariable(name) ?? string.Empty;
foreach (var writer in tty.GetWriters(settings.Target))
{
await writer.WriteLineAsync(value);
}
}
return 0;
}
}

View file

@ -0,0 +1,20 @@
using Spectre.Console.Cli;
namespace Geekeey.Extensions.Process.Tests.Dummy.Commands;
internal sealed class ExitCommand : AsyncCommand<ExitCommand.Settings>
{
public sealed class Settings : CommandSettings
{
[CommandArgument(1, "<code>")] public int Code { get; init; }
}
public override async Task<int> ExecuteAsync(CommandContext context, Settings settings)
{
using var tty = Terminal.Connect();
await tty.Stderr.WriteLineAsync($"Exit code set to {settings.Code}");
return settings.Code;
}
}

View file

@ -0,0 +1,40 @@
using System.Buffers;
using System.Text;
using Spectre.Console.Cli;
namespace Geekeey.Extensions.Process.Tests.Dummy.Commands;
internal sealed class GenerateBlobCommand : AsyncOutputCommand<GenerateBlobCommand.Settings>
{
private readonly Random _random = new(1234567);
public sealed class Settings : OutputCommandSettings
{
[CommandOption("--length")] public long Length { get; init; } = 100_000;
[CommandOption("--buffer")] public int BufferSize { get; init; } = 1024;
}
public override async Task<int> ExecuteAsync(CommandContext context, Settings settings)
{
using var tty = Terminal.Connect();
using var bytes = MemoryPool<byte>.Shared.Rent(settings.BufferSize);
var total = 0L;
while (total < settings.Length)
{
_random.NextBytes(bytes.Memory.Span);
var count = (int)Math.Min(bytes.Memory.Length, settings.Length - total);
foreach (var writer in tty.GetWriters(settings.Target))
{
await writer.BaseStream.WriteAsync(bytes.Memory[..count]);
}
total += count;
}
return 0;
}
}

View file

@ -0,0 +1,42 @@
using System.Buffers;
using System.Text;
using Spectre.Console.Cli;
namespace Geekeey.Extensions.Process.Tests.Dummy.Commands;
internal sealed class GenerateClobCommand : AsyncOutputCommand<GenerateClobCommand.Settings>
{
private readonly Random _random = new(1234567);
private readonly char[] _chars = Enumerable.Range(32, 94).Select(i => (char)i).ToArray();
public sealed class Settings : OutputCommandSettings
{
[CommandOption("--length")] public int Length { get; init; } = 100_000;
[CommandOption("--lines")] public int LinesCount { get; init; } = 1;
}
public override async Task<int> ExecuteAsync(CommandContext context, Settings settings)
{
using var tty = Terminal.Connect();
var buffer = new StringBuilder(settings.Length);
for (var line = 0; line < settings.LinesCount; line++)
{
buffer.Clear();
for (var i = 0; i < settings.Length; i++)
{
buffer.Append(_chars[_random.Next(0, _chars.Length)]);
}
foreach (var writer in tty.GetWriters(settings.Target))
{
await writer.WriteLineAsync(buffer.ToString());
}
}
return 0;
}
}

View file

@ -0,0 +1,37 @@
using System.Buffers;
using System.Globalization;
using Spectre.Console.Cli;
namespace Geekeey.Extensions.Process.Tests.Dummy.Commands;
internal sealed class LengthCommand : AsyncOutputCommand<LengthCommand.Settings>
{
public sealed class Settings : OutputCommandSettings
{
}
public override async Task<int> ExecuteAsync(CommandContext context, Settings settings)
{
using var tty = Terminal.Connect();
using var buffer = MemoryPool<byte>.Shared.Rent(81920);
var count = 0L;
while (true)
{
var bytesRead = await tty.Stdin.BaseStream.ReadAsync(buffer.Memory);
if (bytesRead <= 0)
break;
count += bytesRead;
}
foreach (var writer in tty.GetWriters(settings.Target))
{
await writer.WriteLineAsync(count.ToString(CultureInfo.InvariantCulture));
}
return 0;
}
}

View file

@ -0,0 +1,33 @@
using Spectre.Console.Cli;
namespace Geekeey.Extensions.Process.Tests.Dummy.Commands;
internal sealed class SleepCommand : AsyncCommand<SleepCommand.Settings>
{
public sealed class Settings : CommandSettings
{
[CommandArgument(0, "[duration]")] public TimeSpan Duration { get; init; } = TimeSpan.FromSeconds(1);
}
public override async Task<int> ExecuteAsync(CommandContext context, Settings settings)
{
using var tty = Terminal.Connect();
try
{
await Console.Out.WriteLineAsync($"Sleeping for {settings.Duration}...");
await Console.Out.FlushAsync(CancellationToken.None);
await Task.Delay(settings.Duration, tty.CancellationToken);
}
catch (OperationCanceledException)
{
await Console.Out.WriteLineAsync("Canceled.");
await Console.Out.FlushAsync(CancellationToken.None);
}
await Console.Out.WriteLineAsync("Done.");
await Console.Out.FlushAsync(CancellationToken.None);
return 0;
}
}

View file

@ -0,0 +1,22 @@
using Spectre.Console.Cli;
namespace Geekeey.Extensions.Process.Tests.Dummy.Commands;
internal sealed class WorkingDirectoryCommand : AsyncOutputCommand<WorkingDirectoryCommand.Settings>
{
public sealed class Settings : OutputCommandSettings
{
}
public override async Task<int> ExecuteAsync(CommandContext context, Settings settings)
{
using var tty = Terminal.Connect();
foreach (var writer in tty.GetWriters(settings.Target))
{
await writer.WriteLineAsync(Directory.GetCurrentDirectory());
}
return 0;
}
}

View file

@ -0,0 +1,21 @@
namespace Geekeey.Extensions.Process.Tests.Dummy;
[Flags]
public enum OutputTarget
{
StdOut = 1,
StdErr = 2,
All = StdOut | StdErr
}
internal static class OutputTargetExtensions
{
public static IEnumerable<StreamWriter> GetWriters(this Terminal terminal, OutputTarget target)
{
if (target.HasFlag(OutputTarget.StdOut))
yield return terminal.Stdout;
if (target.HasFlag(OutputTarget.StdErr))
yield return terminal.Stderr;
}
}

View file

@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Spectre.Console" PrivateAssets="compile" />
<PackageReference Include="Spectre.Console.Cli" PrivateAssets="compile" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,44 @@
using System.Reflection;
using System.Runtime.InteropServices;
using Geekeey.Extensions.Process.Tests.Dummy.Commands;
using Spectre.Console.Cli;
namespace Geekeey.Extensions.Process.Tests.Dummy;
public static class Program
{
private static readonly string? FileExtension = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "exe" : null;
#pragma warning disable IL3000 // only for testing where we don't run in single files!
private static readonly string AssemblyPath = Assembly.GetExecutingAssembly().Location;
#pragma warning restore IL3000
public static string FilePath { get; } = Path.ChangeExtension(AssemblyPath, FileExtension);
private static Task<int> Main(string[] args)
{
Environment.SetEnvironmentVariable("DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION", "false");
var app = new CommandApp();
app.Configure(Configuration);
return app.RunAsync(args);
void Configuration(IConfigurator configuration)
{
configuration.AddCommand<EchoCommand>("echo");
configuration.AddCommand<EchoStdinCommand>("echo-stdin");
configuration.AddCommand<EnvironmentCommand>("env");
configuration.AddCommand<WorkingDirectoryCommand>("cwd");
configuration.AddCommand<WorkingDirectoryCommand>("cwd");
configuration.AddCommand<ExitCommand>("exit");
configuration.AddCommand<LengthCommand>("length");
configuration.AddCommand<SleepCommand>("sleep");
configuration.AddBranch("generate", static generate =>
{
generate.AddCommand<GenerateBlobCommand>("blob");
generate.AddCommand<GenerateClobCommand>("clob");
});
}
}
}

View file

@ -0,0 +1,41 @@
namespace Geekeey.Extensions.Process.Tests.Dummy;
internal sealed class Terminal : IDisposable
{
private readonly CancellationTokenSource _cts = new();
public Terminal()
{
Console.CancelKeyPress += Cancel;
}
public StreamReader Stdin { get; } = new(Console.OpenStandardInput(), leaveOpen: false);
public StreamWriter Stdout { get; } = new(Console.OpenStandardOutput(), leaveOpen: false);
public StreamWriter Stderr { get; } = new(Console.OpenStandardError(), leaveOpen: false);
public CancellationToken CancellationToken => _cts.Token;
public static Terminal Connect()
{
return new Terminal();
}
private void Cancel(object? sender, ConsoleCancelEventArgs args)
{
args.Cancel = true;
_cts.Cancel();
}
public void Dispose()
{
Stdout.BaseStream.Flush();
Stdout.Dispose();
Stderr.BaseStream.Flush();
Stderr.Dispose();
Stdin.Dispose();
Console.CancelKeyPress -= Cancel;
_cts.Dispose();
}
}

View file

@ -0,0 +1,85 @@
using Geekeey.Extensions.Process.Buffered;
namespace Geekeey.Extensions.Process.Tests;
[TestFixture]
internal sealed class BufferedExecuteTests
{
private static Command Echo(string target, string value) => new Command(Dummy.Program.FilePath)
.WithArguments(["echo", "--target", target, value]);
[Test]
public async Task I_can_execute_a_command_with_buffering_and_get_the_stdout()
{
// Arrange
var cmd = Echo("stdout", "Hello stdout");
// Act
var result = await cmd.ExecuteBufferedAsync();
Assume.That(result.ExitCode, Is.EqualTo(0));
// Assert
Assert.Multiple(() =>
{
Assert.That(result.StandardOutput.Trim(), Is.EqualTo("Hello stdout"));
Assert.That(result.StandardError.Trim(), Is.Empty);
});
}
[Test]
public async Task I_can_execute_a_command_with_buffering_and_get_the_stderr()
{
// Arrange
var cmd = Echo("stderr", "Hello stderr");
// Act
var result = await cmd.ExecuteBufferedAsync();
Assume.That(result.ExitCode, Is.EqualTo(0));
// Assert
Assert.Multiple(() =>
{
Assert.That(result.StandardOutput.Trim(), Is.Empty);
Assert.That(result.StandardError.Trim(), Is.EqualTo("Hello stderr"));
});
}
[Test]
public async Task I_can_execute_a_command_with_buffering_and_get_the_stdout_and_stderr()
{
// Arrange
var cmd = Echo("all", "Hello stdout and stderr");
// Act
var result = await cmd.ExecuteBufferedAsync();
Assume.That(result.ExitCode, Is.EqualTo(0));
// Assert
Assert.Multiple(() =>
{
Assert.That(result.StandardOutput.Trim(), Is.EqualTo("Hello stdout and stderr"));
Assert.That(result.StandardError.Trim(), Is.EqualTo("Hello stdout and stderr"));
});
}
[Test]
public async Task I_can_execute_a_command_with_buffering_and_not_hang_on_large_stdout_and_stderr()
{
// Arrange
var cmd = new Command(Dummy.Program.FilePath)
.WithArguments(["generate", "clob", "--target", "all", "--length", "100000"]);
// Act
var result = await cmd.ExecuteBufferedAsync();
// Assert
Assert.Multiple(() =>
{
Assert.That(result.StandardOutput.Trim(), Is.Not.Empty.Or.Null);
Assert.That(result.StandardError.Trim(), Is.Not.Empty.Or.Null);
});
}
}

View file

@ -0,0 +1,177 @@
using System.Text;
using Geekeey.Extensions.Process.Buffered;
namespace Geekeey.Extensions.Process.Tests;
[TestFixture]
internal sealed class CancellationTests
{
private static Action<string> NotifyOnStart(out TaskCompletionSource tcs)
{
// run the continuation async on the thread pool, to allow the io reader to complte
var source = tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
return line =>
{
if (line.Contains("Sleeping for", StringComparison.OrdinalIgnoreCase))
source.SetResult();
};
}
[Test]
public async Task I_can_execute_a_command_and_cancel_it_immediately()
{
// Arrange
using var cts = new CancellationTokenSource();
var stdout = new StringBuilder();
var target = PipeTarget.Merge(
PipeTarget.ToDelegate(NotifyOnStart(out var tcs)),
PipeTarget.ToStringBuilder(stdout)
);
var cmd = new Command(Dummy.Program.FilePath)
.WithArguments(["sleep", "00:00:30"])
| target;
// Act
var task = cmd.ExecuteAsync(cts.Token);
await tcs.Task;
await cts.CancelAsync();
// Assert
var exception = Assert.ThrowsAsync<OperationCanceledException>(async () => await task);
Assert.Multiple(() =>
{
Assert.That(ProcessTree.HasExited(task.ProcessId));
Assert.That(stdout.ToString(), Does.Contain("Sleeping for"));
Assert.That(stdout.ToString(), Does.Not.Contain("Done."));
});
}
[Test]
public async Task I_can_execute_a_command_and_kill_it_immediately()
{
// Arrange
var stdout = new StringBuilder();
var target = PipeTarget.Merge(
PipeTarget.ToDelegate(NotifyOnStart(out var tcs)),
PipeTarget.ToStringBuilder(stdout)
);
var cmd = new Command(Dummy.Program.FilePath)
.WithArguments(["sleep", "00:00:30"])
| target;
// Act
var task = cmd.ExecuteAsync();
await tcs.Task;
task.Kill();
// Assert
var exception = Assert.ThrowsAsync<CommandExecutionException>(async () => await task);
Assert.Multiple(() =>
{
Assert.That(ProcessTree.HasExited(task.ProcessId));
Assert.That(stdout.ToString(), Does.Contain("Sleeping for"));
Assert.That(stdout.ToString(), Does.Not.Contain("Done."));
});
}
[Test]
public async Task I_can_execute_a_command_with_buffering_and_kill_it_immediately()
{
// Arrange
var stdout = new StringBuilder();
var target = PipeTarget.Merge(
PipeTarget.ToDelegate(NotifyOnStart(out var tcs)),
PipeTarget.ToStringBuilder(stdout)
);
var cmd = new Command(Dummy.Program.FilePath)
.WithArguments(["sleep", "00:00:30"])
| target;
// Act
var task = cmd.ExecuteBufferedAsync();
await tcs.Task;
task.Kill();
// Assert
var exception = Assert.ThrowsAsync<CommandExecutionException>(async () => await task);
Assert.Multiple(() =>
{
Assert.That(ProcessTree.HasExited(task.ProcessId));
Assert.That(stdout.ToString(), Does.Contain("Sleeping for"));
Assert.That(stdout.ToString(), Does.Not.Contain("Done."));
});
}
[Test]
public async Task I_can_execute_a_command_and_interrupt_it_immediately()
{
// Arrange
var stdout = new StringBuilder();
var target = PipeTarget.Merge(
PipeTarget.ToDelegate(NotifyOnStart(out var tcs)),
PipeTarget.ToStringBuilder(stdout)
);
var cmd = new Command(Dummy.Program.FilePath)
.WithArguments(["sleep", "00:00:30"])
| target;
// Act
var task = cmd.ExecuteAsync();
await tcs.Task;
task.Interrupt();
// Assert
Assert.DoesNotThrowAsync(async () => await task);
Assert.Multiple(() =>
{
Assert.That(ProcessTree.HasExited(task.ProcessId));
Assert.That(stdout.ToString(), Does.Contain("Sleeping for"));
Assert.That(stdout.ToString(), Does.Contain("Done."));
});
}
[Test]
public async Task I_can_execute_a_command_with_buffering_and_interrupt_it_immediately()
{
// Arrange
var stdout = new StringBuilder();
var target = PipeTarget.Merge(
PipeTarget.ToDelegate(NotifyOnStart(out var tcs)),
PipeTarget.ToStringBuilder(stdout)
);
var cmd = new Command(Dummy.Program.FilePath)
.WithArguments(["sleep", "00:00:30"])
| target;
// Act
var task = cmd.ExecuteBufferedAsync();
await tcs.Task;
task.Interrupt();
// Assert
Assert.DoesNotThrowAsync(async () => await task);
Assert.Multiple(() =>
{
Assert.That(ProcessTree.HasExited(task.ProcessId));
Assert.That(stdout.ToString(), Does.Contain("Sleeping for"));
Assert.That(stdout.ToString(), Does.Contain("Done."));
});
}
}

View file

@ -0,0 +1,271 @@
namespace Geekeey.Extensions.Process.Tests;
[TestFixture]
internal sealed class CommandTests
{
[Test]
public void I_can_create_a_command_with_the_default_configuration()
{
var cmd = new Command("foo");
Assert.Multiple(() =>
{
Assert.That(cmd.TargetFilePath, Is.EqualTo("foo"));
Assert.That(cmd.Arguments, Is.Empty);
Assert.That(cmd.WorkingDirPath, Is.EqualTo(Directory.GetCurrentDirectory()));
Assert.That(cmd.EnvironmentVariables, Is.Empty);
Assert.That(cmd.Validation, Is.EqualTo(CommandExitBehaviour.ZeroExitCode));
Assert.That(cmd.StandardInputPipe, Is.EqualTo(PipeSource.Null));
Assert.That(cmd.StandardOutputPipe, Is.EqualTo(PipeTarget.Null));
Assert.That(cmd.StandardErrorPipe, Is.EqualTo(PipeTarget.Null));
});
}
[Test]
public void I_can_configure_the_target_file()
{
var cmd = new Command("foo");
var modified = cmd.WithTargetFile("bar");
Assert.Multiple(() =>
{
Assert.That(modified.TargetFilePath, Is.EqualTo("bar"));
Assert.That(modified.Arguments, Is.EqualTo(cmd.Arguments));
Assert.That(modified.WorkingDirPath, Is.EqualTo(cmd.WorkingDirPath));
Assert.That(modified.EnvironmentVariables, Is.EqualTo(cmd.EnvironmentVariables));
Assert.That(modified.Validation, Is.EqualTo(cmd.Validation));
Assert.That(modified.StandardInputPipe, Is.EqualTo(cmd.StandardInputPipe));
Assert.That(modified.StandardOutputPipe, Is.EqualTo(cmd.StandardOutputPipe));
Assert.That(modified.StandardErrorPipe, Is.EqualTo(cmd.StandardErrorPipe));
Assert.That(cmd.TargetFilePath, Is.Not.EqualTo("bar"));
});
}
[Test]
public void I_can_configure_the_command_line_arguments()
{
var cmd = new Command("foo").WithArguments("xxx");
var modified = cmd.WithArguments("abc def");
Assert.Multiple(() =>
{
Assert.That(modified.TargetFilePath, Is.EqualTo(cmd.TargetFilePath));
Assert.That(modified.Arguments, Is.EqualTo("abc def"));
Assert.That(modified.WorkingDirPath, Is.EqualTo(cmd.WorkingDirPath));
Assert.That(modified.EnvironmentVariables, Is.EqualTo(cmd.EnvironmentVariables));
Assert.That(modified.Validation, Is.EqualTo(cmd.Validation));
Assert.That(modified.StandardInputPipe, Is.EqualTo(cmd.StandardInputPipe));
Assert.That(modified.StandardOutputPipe, Is.EqualTo(cmd.StandardOutputPipe));
Assert.That(modified.StandardErrorPipe, Is.EqualTo(cmd.StandardErrorPipe));
Assert.That(cmd.Arguments, Is.Not.EqualTo("abc def"));
});
}
[Test]
public void I_can_configure_the_command_line_arguments_by_passing_an_array()
{
var cmd = new Command("foo").WithArguments("xxx");
var modified = cmd.WithArguments(["abc", "def"]);
Assert.Multiple(() =>
{
Assert.That(modified.TargetFilePath, Is.EqualTo(cmd.TargetFilePath));
Assert.That(modified.Arguments, Is.EqualTo("abc def"));
Assert.That(modified.WorkingDirPath, Is.EqualTo(cmd.WorkingDirPath));
Assert.That(modified.EnvironmentVariables, Is.EqualTo(cmd.EnvironmentVariables));
Assert.That(modified.Validation, Is.EqualTo(cmd.Validation));
Assert.That(modified.StandardInputPipe, Is.EqualTo(cmd.StandardInputPipe));
Assert.That(modified.StandardOutputPipe, Is.EqualTo(cmd.StandardOutputPipe));
Assert.That(modified.StandardErrorPipe, Is.EqualTo(cmd.StandardErrorPipe));
Assert.That(cmd.Arguments, Is.Not.EqualTo("abc def"));
});
}
[Test]
public void I_can_configure_the_command_line_arguments_using_a_builder()
{
var cmd = new Command("foo").WithArguments("xxx");
var modified = cmd.WithArguments(args => args
.Add("-a")
.Add("foo bar")
.Add("\"foo\\\\bar\"")
.Add(3.14)
.Add(["foo", "bar"])
.Add([-10, 12.12]));
Assert.Multiple(() =>
{
Assert.That(modified.TargetFilePath, Is.EqualTo(cmd.TargetFilePath));
Assert.That(modified.Arguments, Is.EqualTo("-a \"foo bar\" \"\\\"foo\\\\bar\\\"\" 3.14 foo bar -10 12.12"));
Assert.That(modified.WorkingDirPath, Is.EqualTo(cmd.WorkingDirPath));
Assert.That(modified.EnvironmentVariables, Is.EqualTo(cmd.EnvironmentVariables));
Assert.That(modified.Validation, Is.EqualTo(cmd.Validation));
Assert.That(modified.StandardInputPipe, Is.EqualTo(cmd.StandardInputPipe));
Assert.That(modified.StandardOutputPipe, Is.EqualTo(cmd.StandardOutputPipe));
Assert.That(modified.StandardErrorPipe, Is.EqualTo(cmd.StandardErrorPipe));
Assert.That(cmd.Arguments, Is.Not.EqualTo("-a \"foo bar\" \"\\\"foo\\\\bar\\\"\" 3.14 foo bar -10 12.12"));
});
}
[Test]
public void I_can_configure_the_working_directory()
{
var cmd = new Command("foo").WithWorkingDirectory("xxx");
var modified = cmd.WithWorkingDirectory("new");
Assert.Multiple(() =>
{
Assert.That(modified.TargetFilePath, Is.EqualTo(cmd.TargetFilePath));
Assert.That(modified.Arguments, Is.EqualTo(cmd.Arguments));
Assert.That(modified.WorkingDirPath, Is.EqualTo("new"));
Assert.That(modified.EnvironmentVariables, Is.EqualTo(cmd.EnvironmentVariables));
Assert.That(modified.Validation, Is.EqualTo(cmd.Validation));
Assert.That(modified.StandardInputPipe, Is.EqualTo(cmd.StandardInputPipe));
Assert.That(modified.StandardOutputPipe, Is.EqualTo(cmd.StandardOutputPipe));
Assert.That(modified.StandardErrorPipe, Is.EqualTo(cmd.StandardErrorPipe));
Assert.That(cmd.WorkingDirPath, Is.Not.EqualTo("new"));
});
}
[Test]
public void I_can_configure_the_environment_variables()
{
var cmd = new Command("foo").WithEnvironmentVariables(e => e.Set("xxx", "xxx"));
var vars = new Dictionary<string, string?> { ["name"] = "value", ["key"] = "door" };
var modified = cmd.WithEnvironmentVariables(vars);
Assert.Multiple(() =>
{
Assert.That(modified.TargetFilePath, Is.EqualTo(cmd.TargetFilePath));
Assert.That(modified.Arguments, Is.EqualTo(cmd.Arguments));
Assert.That(modified.WorkingDirPath, Is.EqualTo(cmd.WorkingDirPath));
Assert.That(modified.EnvironmentVariables, Is.EqualTo(vars));
Assert.That(modified.Validation, Is.EqualTo(cmd.Validation));
Assert.That(modified.StandardInputPipe, Is.EqualTo(cmd.StandardInputPipe));
Assert.That(modified.StandardOutputPipe, Is.EqualTo(cmd.StandardOutputPipe));
Assert.That(modified.StandardErrorPipe, Is.EqualTo(cmd.StandardErrorPipe));
Assert.That(cmd.EnvironmentVariables, Is.Not.EqualTo(vars));
});
}
[Test]
public void I_can_configure_the_environment_variables_using_a_builder()
{
var cmd = new Command("foo").WithEnvironmentVariables(e => e.Set("xxx", "xxx"));
var modified = cmd.WithEnvironmentVariables(env => env
.Set("name", "value")
.Set("key", "door")
.Set(new Dictionary<string, string?> { ["zzz"] = "yyy", ["aaa"] = "bbb" }));
Assert.Multiple(() =>
{
var vars = new Dictionary<string, string?>
{
["name"] = "value", ["key"] = "door", ["zzz"] = "yyy", ["aaa"] = "bbb"
};
Assert.That(modified.TargetFilePath, Is.EqualTo(cmd.TargetFilePath));
Assert.That(modified.Arguments, Is.EqualTo(cmd.Arguments));
Assert.That(modified.WorkingDirPath, Is.EqualTo(cmd.WorkingDirPath));
Assert.That(modified.EnvironmentVariables, Is.EqualTo(vars));
Assert.That(modified.Validation, Is.EqualTo(cmd.Validation));
Assert.That(modified.StandardInputPipe, Is.EqualTo(cmd.StandardInputPipe));
Assert.That(modified.StandardOutputPipe, Is.EqualTo(cmd.StandardOutputPipe));
Assert.That(modified.StandardErrorPipe, Is.EqualTo(cmd.StandardErrorPipe));
Assert.That(cmd.EnvironmentVariables, Is.Not.EqualTo(vars));
});
}
[Test]
public void I_can_configure_the_result_validation_strategy()
{
var cmd = new Command("foo").WithValidation(CommandExitBehaviour.ZeroExitCode);
var modified = cmd.WithValidation(CommandExitBehaviour.None);
Assert.Multiple(() =>
{
Assert.That(modified.TargetFilePath, Is.EqualTo(cmd.TargetFilePath));
Assert.That(modified.Arguments, Is.EqualTo(cmd.Arguments));
Assert.That(modified.WorkingDirPath, Is.EqualTo(cmd.WorkingDirPath));
Assert.That(modified.EnvironmentVariables, Is.EqualTo(cmd.EnvironmentVariables));
Assert.That(modified.Validation, Is.EqualTo(CommandExitBehaviour.None));
Assert.That(modified.StandardInputPipe, Is.EqualTo(cmd.StandardInputPipe));
Assert.That(modified.StandardOutputPipe, Is.EqualTo(cmd.StandardOutputPipe));
Assert.That(modified.StandardErrorPipe, Is.EqualTo(cmd.StandardErrorPipe));
Assert.That(cmd.Validation, Is.Not.EqualTo(CommandExitBehaviour.None));
});
}
[Test]
public void I_can_configure_the_stdin_pipe()
{
var cmd = new Command("foo").WithStandardInputPipe(PipeSource.Null);
var pipeSource = PipeSource.FromStream(Stream.Null);
var modified = cmd.WithStandardInputPipe(pipeSource);
Assert.Multiple(() =>
{
Assert.That(modified.TargetFilePath, Is.EqualTo(cmd.TargetFilePath));
Assert.That(modified.Arguments, Is.EqualTo(cmd.Arguments));
Assert.That(modified.WorkingDirPath, Is.EqualTo(cmd.WorkingDirPath));
Assert.That(modified.EnvironmentVariables, Is.EqualTo(cmd.EnvironmentVariables));
Assert.That(modified.Validation, Is.EqualTo(cmd.Validation));
Assert.That(modified.StandardInputPipe, Is.EqualTo(pipeSource));
Assert.That(modified.StandardOutputPipe, Is.EqualTo(cmd.StandardOutputPipe));
Assert.That(modified.StandardErrorPipe, Is.EqualTo(cmd.StandardErrorPipe));
Assert.That(cmd.StandardInputPipe, Is.Not.EqualTo(pipeSource));
});
}
[Test]
public void I_can_configure_the_stdout_pipe()
{
var cmd = new Command("foo").WithStandardOutputPipe(PipeTarget.Null);
var pipeTarget = PipeTarget.ToStream(Stream.Null);
var modified = cmd.WithStandardOutputPipe(pipeTarget);
Assert.Multiple(() =>
{
Assert.That(modified.TargetFilePath, Is.EqualTo(cmd.TargetFilePath));
Assert.That(modified.Arguments, Is.EqualTo(cmd.Arguments));
Assert.That(modified.WorkingDirPath, Is.EqualTo(cmd.WorkingDirPath));
Assert.That(modified.EnvironmentVariables, Is.EqualTo(cmd.EnvironmentVariables));
Assert.That(modified.Validation, Is.EqualTo(cmd.Validation));
Assert.That(modified.StandardInputPipe, Is.EqualTo(cmd.StandardInputPipe));
Assert.That(modified.StandardOutputPipe, Is.EqualTo(pipeTarget));
Assert.That(modified.StandardErrorPipe, Is.EqualTo(cmd.StandardErrorPipe));
Assert.That(cmd.StandardOutputPipe, Is.Not.EqualTo(pipeTarget));
});
}
[Test]
public void I_can_configure_the_stderr_pipe()
{
var cmd = new Command("foo").WithStandardErrorPipe(PipeTarget.Null);
var pipeTarget = PipeTarget.ToStream(Stream.Null);
var modified = cmd.WithStandardErrorPipe(pipeTarget);
Assert.Multiple(() =>
{
Assert.That(modified.TargetFilePath, Is.EqualTo(cmd.TargetFilePath));
Assert.That(modified.Arguments, Is.EqualTo(cmd.Arguments));
Assert.That(modified.WorkingDirPath, Is.EqualTo(cmd.WorkingDirPath));
Assert.That(modified.EnvironmentVariables, Is.EqualTo(cmd.EnvironmentVariables));
Assert.That(modified.Validation, Is.EqualTo(cmd.Validation));
Assert.That(modified.StandardInputPipe, Is.EqualTo(cmd.StandardInputPipe));
Assert.That(modified.StandardOutputPipe, Is.EqualTo(cmd.StandardOutputPipe));
Assert.That(modified.StandardErrorPipe, Is.EqualTo(pipeTarget));
Assert.That(cmd.StandardErrorPipe, Is.Not.EqualTo(pipeTarget));
});
}
}

View file

@ -0,0 +1,137 @@
using System.ComponentModel;
using Geekeey.Extensions.Process.Buffered;
namespace Geekeey.Extensions.Process.Tests;
[TestFixture]
internal sealed class ExecuteTests
{
[Test]
public async Task I_can_execute_a_command_and_get_the_exit_code_and_execution_time()
{
// Arrange
var cmd = new Command(Dummy.Program.FilePath)
.WithArguments(["echo"]);
// Act
var result = await cmd.ExecuteAsync();
Assume.That(result.ExitCode, Is.EqualTo(0));
// Assert
Assert.Multiple(() =>
{
Assert.That(result.ExitCode, Is.EqualTo(0));
Assert.That(result.IsSuccess, Is.True);
Assert.That(result.RunTime, Is.GreaterThan(TimeSpan.Zero));
});
}
[Test]
public async Task I_can_execute_a_command_and_get_the_associated_process_id()
{
// Arrange
var cmd = new Command(Dummy.Program.FilePath)
.WithArguments(["echo"]);
// Act
var task = cmd.ExecuteAsync();
// Assert
Assert.That(task.ProcessId, Is.Not.EqualTo(0));
await task;
}
[Test]
public async Task I_can_execute_a_command_with_a_configured_awaiter()
{
// Arrange
var cmd = new Command(Dummy.Program.FilePath)
.WithArguments(["echo"]);
// Act + Assert
await cmd.ExecuteAsync().ConfigureAwait(false);
}
[Test]
public Task I_can_try_to_execute_a_command_and_get_an_error_if_the_target_file_does_not_exist()
{
// Arrange
var cmd = new Command("some_exe_with_does_not_exits");
// Act + Assert
Assert.ThrowsAsync<Win32Exception>(async () => await cmd.ExecuteAsync());
return Task.CompletedTask;
}
[Test]
public async Task I_can_execute_a_command_with_a_custom_working_directory()
{
// Arrange
using var dir = TestTempDirectory.Create();
var cmd = new Command(Dummy.Program.FilePath)
.WithArguments("cwd")
.WithWorkingDirectory(dir.Path);
// Act
var result = await cmd.ExecuteBufferedAsync();
Assume.That(result.ExitCode, Is.EqualTo(0));
// Assert
var lines = result.StandardOutput.Split(Environment.NewLine);
Assert.That(lines, Is.SupersetOf(new[] { dir.Path }));
}
[Test]
public async Task I_can_execute_a_command_with_additional_environment_variables()
{
// Arrange
var cmd = new Command(Dummy.Program.FilePath)
.WithArguments(["env", "foo", "bar"])
.WithEnvironmentVariables(env => env
.Set("foo", "hello")
.Set("bar", "world"));
// Act
var result = await cmd.ExecuteBufferedAsync();
Assume.That(result.ExitCode, Is.EqualTo(0));
// Assert
var lines = result.StandardOutput.Split(Environment.NewLine);
Assert.That(lines, Is.SupersetOf(new[] { "hello", "world" }));
}
[Test]
public async Task I_can_execute_a_command_with_some_environment_variables_overwritten()
{
// Arrange
var key = Guid.NewGuid();
var variableToKeep = $"GKY_TEST_KEEP_{key}";
var variableToOverwrite = $"GKY_TEST_OVERWRITE_{key}";
var variableToUnset = $"GKY_TEST_UNSET_{key}";
using var _a = (TestEnvironment.Create(variableToKeep, "keep"));
using var _b = (TestEnvironment.Create(variableToOverwrite, "overwrite"));
using var _c = (TestEnvironment.Create(variableToUnset, "unset"));
var cmd = new Command(Dummy.Program.FilePath)
.WithArguments(["env", variableToKeep, variableToOverwrite, variableToUnset])
.WithEnvironmentVariables(env => env
.Set(variableToOverwrite, "overwritten")
.Set(variableToUnset, null));
// Act
var result = await cmd.ExecuteBufferedAsync();
Assume.That(result.ExitCode, Is.EqualTo(0));
// Assert
var lines = result.StandardOutput.Split(Environment.NewLine);
Assert.That(lines, Is.SupersetOf(new[] { "keep", "overwritten" }));
}
}

View file

@ -0,0 +1,18 @@
namespace Geekeey.Extensions.Process.Tests;
internal static class ProcessTree
{
public static bool HasExited(int id)
{
try
{
using var process = System.Diagnostics.Process.GetProcessById(id);
return process.HasExited;
}
catch
{
// GetProcessById throws if the process can not be found, which means it is not running!
return true;
}
}
}

View file

@ -0,0 +1,19 @@
namespace Geekeey.Extensions.Process.Tests;
internal sealed class TestEnvironment(Action action) : IDisposable
{
public static TestEnvironment Create(string name, string? value)
{
var lastValue = Environment.GetEnvironmentVariable(name);
Environment.SetEnvironmentVariable(name, value);
return new TestEnvironment(() => Environment.SetEnvironmentVariable(name, lastValue));
}
public static TestEnvironment ExtendPath(string path)
{
return Create("PATH", Environment.GetEnvironmentVariable("PATH") + Path.PathSeparator + path);
}
public void Dispose() => action();
}

View file

@ -0,0 +1,28 @@
using System.Reflection;
namespace Geekeey.Extensions.Process.Tests;
internal sealed class TestTempDirectory(string path) : IDisposable
{
public static TestTempDirectory Create()
{
var location = Assembly.GetExecutingAssembly().Location;
var pwd = System.IO.Path.GetDirectoryName(location) ?? Directory.GetCurrentDirectory();
var dirPath = System.IO.Path.Combine(pwd, "Temp", Guid.NewGuid().ToString());
Directory.CreateDirectory(dirPath);
return new TestTempDirectory(dirPath);
}
public string Path { get; } = path;
public void Dispose()
{
try
{
Directory.Delete(Path, true);
}
catch (DirectoryNotFoundException) { }
}
}

View file

@ -0,0 +1,30 @@
using System.Reflection;
namespace Geekeey.Extensions.Process.Tests;
internal sealed class TestTempFile(string path) : IDisposable
{
public static TestTempFile Create()
{
var location = Assembly.GetExecutingAssembly().Location;
var pwd = System.IO.Path.GetDirectoryName(location) ?? Directory.GetCurrentDirectory();
var dirPath = System.IO.Path.Combine(pwd, "Temp");
Directory.CreateDirectory(dirPath);
var filePath = System.IO.Path.Combine(dirPath, Guid.NewGuid() + ".tmp");
return new TestTempFile(filePath);
}
public string Path { get; } = path;
public void Dispose()
{
try
{
File.Delete(Path);
}
catch (FileNotFoundException) { }
}
}

View file

@ -0,0 +1,77 @@
namespace Geekeey.Extensions.Process.Tests;
[TestFixture]
internal sealed class LineBreakTests
{
private static Command Echo()
=> new Command(Dummy.Program.FilePath)
.WithArguments("echo-stdin");
[Test]
public async Task I_can_execute_a_command_and_split_the_stdout_by_newline()
{
// Arrange
const string data = "Foo\nBar\nBaz";
var stdOutLines = new List<string>();
var cmd = data | Echo() | stdOutLines.Add;
// Act
await cmd.ExecuteAsync();
// Assert
Assert.That(stdOutLines, Is.EquivalentTo(new[] { "Foo", "Bar", "Baz" }));
}
[Test]
public async Task I_can_execute_a_command_and_split_the_stdout_by_caret_return()
{
// Arrange
const string data = "Foo\rBar\rBaz";
var stdOutLines = new List<string>();
var cmd = data | Echo() | stdOutLines.Add;
// Act
await cmd.ExecuteAsync();
// Assert
Assert.That(stdOutLines, Is.EquivalentTo(new[] { "Foo", "Bar", "Baz" }));
}
[Test]
public async Task I_can_execute_a_command_and_split_the_stdout_by_caret_return_followed_by_newline()
{
// Arrange
const string data = "Foo\r\nBar\r\nBaz";
var stdOutLines = new List<string>();
var cmd = data | Echo() | stdOutLines.Add;
// Act
await cmd.ExecuteAsync();
// Assert
Assert.That(stdOutLines, Is.EquivalentTo(new[] { "Foo", "Bar", "Baz" }));
}
[Test]
public async Task I_can_execute_a_command_and_split_the_stdout_by_newline_while_including_empty_lines()
{
// Arrange
const string data = "Foo\r\rBar\n\nBaz";
var stdOutLines = new List<string>();
var cmd = data | Echo() | stdOutLines.Add;
// Act
await cmd.ExecuteAsync();
// Assert
Assert.That(stdOutLines, Is.EquivalentTo(new[] { "Foo", "", "Bar", "", "Baz" }));
}
}

View file

@ -0,0 +1,48 @@
using Geekeey.Extensions.Process.Buffered;
namespace Geekeey.Extensions.Process.Tests;
[TestFixture]
internal sealed class PathResolutionTests
{
[Test]
public async Task I_can_execute_a_command_on_an_executable_using_its_short_name()
{
// Arrange
var cmd = new Command("dotnet")
.WithArguments("--version");
// Act
var result = await cmd.ExecuteBufferedAsync();
// Assert
Assert.Multiple(() =>
{
Assert.That(result.IsSuccess, Is.True);
Assert.That(result.StandardOutput.Trim(), Does.Match(@"^\d+\.\d+\.\d+$"));
});
}
[Test]
[Platform("win")]
public async Task I_can_execute_a_command_on_a_script_using_its_short_name()
{
// Arrange
using var dir = TestTempDirectory.Create();
await File.WriteAllTextAsync(Path.Combine(dir.Path, "script.cmd"), "@echo hi");
using var _1 = TestEnvironment.ExtendPath(dir.Path);
var cmd = new Command("script");
// Act
var result = await cmd.ExecuteBufferedAsync();
// Assert
// Assert
Assert.Multiple(() =>
{
Assert.That(result.IsSuccess, Is.True);
Assert.That(result.StandardOutput.Trim(), Is.EqualTo("hi"));
});
}
}

View file

@ -0,0 +1,520 @@
using System.Text;
using Geekeey.Extensions.Process.Buffered;
namespace Geekeey.Extensions.Process.Tests;
[TestFixture]
internal sealed class PipingTests
{
#region Stdin
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdin_from_an_async_anonymous_source()
{
// Arrange
var source = PipeSource.Create(async (destination, cancellationToken)
=> await destination.WriteAsync("Hello World!"u8.ToArray(), cancellationToken));
var cmd =
source | new Command(Dummy.Program.FilePath)
.WithArguments("echo-stdin");
// Act
var result = await cmd.ExecuteBufferedAsync();
// Assert
Assert.That(result.StandardOutput.Trim(), Is.EqualTo("Hello World!"));
}
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdin_from_a_sync_anonymous_source()
{
// Arrange
var source = PipeSource.Create(destination
=> destination.Write("Hello World!"u8.ToArray()));
var cmd =
source | new Command(Dummy.Program.FilePath)
.WithArguments("echo-stdin");
// Act
var result = await cmd.ExecuteBufferedAsync();
// Assert
Assert.That(result.StandardOutput.Trim(), Is.EqualTo("Hello World!"));
}
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdin_from_a_stream()
{
// Arrange
using var source = new MemoryStream("Hello World!"u8.ToArray());
var cmd =
source | new Command(Dummy.Program.FilePath)
.WithArguments("echo-stdin");
// Act
var result = await cmd.ExecuteBufferedAsync();
// Assert
Assert.That(result.StandardOutput.Trim(), Is.EqualTo("Hello World!"));
}
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdin_from_memory()
{
// Arrange
var data = new ReadOnlyMemory<byte>("Hello World!"u8.ToArray());
var cmd =
data | new Command(Dummy.Program.FilePath)
.WithArguments("echo-stdin");
// Act
var result = await cmd.ExecuteBufferedAsync();
// Assert
Assert.That(result.StandardOutput.Trim(), Is.EqualTo("Hello World!"));
}
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdin_from_a_byte_array()
{
// Arrange
var data = "Hello World!"u8.ToArray();
var cmd =
data | new Command(Dummy.Program.FilePath)
.WithArguments("echo-stdin");
// Act
var result = await cmd.ExecuteBufferedAsync();
// Assert
Assert.That(result.StandardOutput.Trim(), Is.EqualTo("Hello World!"));
}
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdin_from_a_string()
{
// Arrange
var data = "Hello World!";
var cmd =
data | new Command(Dummy.Program.FilePath)
.WithArguments("echo-stdin");
// Act
var result = await cmd.ExecuteBufferedAsync();
// Assert
Assert.That(result.StandardOutput.Trim(), Is.EqualTo("Hello World!"));
}
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdin_from_another_command()
{
// Arrange
var cmd =
new Command(Dummy.Program.FilePath)
.WithArguments(["generate", "blob", "--length", "100000"])
| new Command(Dummy.Program.FilePath)
.WithArguments("length");
// Act
var result = await cmd.ExecuteBufferedAsync();
// Assert
Assert.That(result.StandardOutput.Trim(), Is.EqualTo("100000"));
}
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdin_from_a_chain_of_commands()
{
// Arrange
var cmd =
"Hello world"
| new Command(Dummy.Program.FilePath)
.WithArguments("echo-stdin")
| new Command(Dummy.Program.FilePath)
.WithArguments(["echo-stdin", "--length", "5"])
| new Command(Dummy.Program.FilePath)
.WithArguments("length");
// Act
var result = await cmd.ExecuteBufferedAsync();
// Assert
Assert.That(result.StandardOutput.Trim(), Is.EqualTo("5"));
}
#endregion
#region Stdout
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdout_into_an_async_anonymous_target()
{
// Arrange
using var stream = new MemoryStream();
var target = PipeTarget.Create(async (origin, cancellationToken) =>
// ReSharper disable once AccessToDisposedClosure
await origin.CopyToAsync(stream, cancellationToken)
);
var cmd =
new Command(Dummy.Program.FilePath)
.WithArguments(["generate", "blob", "--length", "100000"]) | target;
// Act
await cmd.ExecuteAsync();
// Assert
Assert.That(stream.Length, Is.EqualTo(100_000));
}
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdout_into_a_sync_anonymous_target()
{
// Arrange
using var stream = new MemoryStream();
var target = PipeTarget.Create(origin =>
// ReSharper disable once AccessToDisposedClosure
origin.CopyTo(stream)
);
var cmd =
new Command(Dummy.Program.FilePath)
.WithArguments(["generate", "blob", "--length", "100000"]) | target;
// Act
await cmd.ExecuteAsync();
// Assert
Assert.That(stream.Length, Is.EqualTo(100_000));
}
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdout_into_a_stream()
{
// Arrange
using var stream = new MemoryStream();
var cmd =
new Command(Dummy.Program.FilePath)
.WithArguments(["generate", "blob", "--length", "100000"]) | stream;
// Act
await cmd.ExecuteAsync();
// Assert
Assert.That(stream.Length, Is.EqualTo(100_000));
}
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdout_into_a_string_builder()
{
// Arrange
var buffer = new StringBuilder();
var cmd =
new Command(Dummy.Program.FilePath)
.WithArguments(["echo", "Hello World!"]) | buffer;
// Act
await cmd.ExecuteAsync();
// Assert
Assert.That(buffer.ToString().Trim(), Is.EqualTo("Hello World!"));
}
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdout_into_an_async_delegate()
{
// Arrange
var stdOutLinesCount = 0;
async Task HandleStdOutAsync(string line)
{
await Task.Yield();
stdOutLinesCount++;
}
var cmd =
new Command(Dummy.Program.FilePath)
.WithArguments(["generate", "clob", "--lines", "100"])
| HandleStdOutAsync;
// Act
await cmd.ExecuteAsync();
// Assert
Assert.That(stdOutLinesCount, Is.EqualTo(100));
}
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdout_into_an_async_delegate_with_cancellation()
{
// Arrange
var stdOutLinesCount = 0;
async Task HandleStdOutAsync(string line, CancellationToken cancellationToken = default)
{
await Task.Delay(1, cancellationToken);
stdOutLinesCount++;
}
var cmd =
new Command(Dummy.Program.FilePath)
.WithArguments(["generate", "clob", "--lines", "100"])
| HandleStdOutAsync;
// Act
await cmd.ExecuteAsync();
// Assert
Assert.That(stdOutLinesCount, Is.EqualTo(100));
}
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdout_into_a_sync_delegate()
{
// Arrange
var stdOutLinesCount = 0;
void HandleStdOut(string line) => stdOutLinesCount++;
var cmd =
new Command(Dummy.Program.FilePath)
.WithArguments(["generate", "clob", "--lines", "100"])
| HandleStdOut;
// Act
await cmd.ExecuteAsync();
// Assert
Assert.That(stdOutLinesCount, Is.EqualTo(100));
}
#endregion
#region Stdout & Stderr
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdout_and_stderr_into_separate_stream()
{
// Arrange
using var stdOut = new MemoryStream();
using var stdErr = new MemoryStream();
var cmd =
new Command(Dummy.Program.FilePath)
.WithArguments(["generate", "blob", "--target", "all", "--length", "100000"])
| (stdOut, stdErr);
// Act
await cmd.ExecuteAsync();
// Assert
Assert.Multiple(() =>
{
Assert.That(stdOut.Length, Is.EqualTo(100_000));
Assert.That(stdErr.Length, Is.EqualTo(100_000));
});
}
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdout_and_stderr_into_string_builder()
{
// Arrange
var stdOutBuffer = new StringBuilder();
var stdErrBuffer = new StringBuilder();
var cmd =
new Command(Dummy.Program.FilePath)
.WithArguments(["echo", "Hello world!", "--target", "all"])
| (stdOutBuffer, stdErrBuffer);
// Act
await cmd.ExecuteAsync();
// Assert
Assert.Multiple(() =>
{
Assert.That(stdOutBuffer.ToString().Trim(), Is.EqualTo("Hello world!"));
Assert.That(stdErrBuffer.ToString().Trim(), Is.EqualTo("Hello world!"));
});
}
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdout_and_stderr_into_separate_async_delegate()
{
// Arrange
var stdOutLinesCount = 0;
var stdErrLinesCount = 0;
async Task HandleStdOutAsync(string line)
{
await Task.Yield();
stdOutLinesCount++;
}
async Task HandleStdErrAsync(string line)
{
await Task.Yield();
stdErrLinesCount++;
}
var cmd =
new Command(Dummy.Program.FilePath)
.WithArguments(["generate", "clob", "--target", "all", "--lines", "100"])
| (HandleStdOutAsync, HandleStdErrAsync);
// Act
await cmd.ExecuteAsync();
// Assert
Assert.Multiple(() =>
{
Assert.That(stdOutLinesCount, Is.EqualTo(100));
Assert.That(stdErrLinesCount, Is.EqualTo(100));
});
}
[Test]
public async Task
I_can_execute_a_command_and_pipe_the_stdout_and_stderr_into_separate_async_delegate_with_cancellation()
{
// Arrange
var stdOutLinesCount = 0;
var stdErrLinesCount = 0;
async Task HandleStdOutAsync(string line, CancellationToken cancellationToken = default)
{
await Task.Delay(1, cancellationToken);
stdOutLinesCount++;
}
async Task HandleStdErrAsync(string line, CancellationToken cancellationToken = default)
{
await Task.Delay(1, cancellationToken);
stdErrLinesCount++;
}
var cmd =
new Command(Dummy.Program.FilePath)
.WithArguments(["generate", "clob", "--target", "all", "--lines", "100"])
| (HandleStdOutAsync, HandleStdErrAsync);
// Act
await cmd.ExecuteAsync();
// Assert
Assert.Multiple(() =>
{
Assert.That(stdOutLinesCount, Is.EqualTo(100));
Assert.That(stdErrLinesCount, Is.EqualTo(100));
});
}
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdout_and_stderr_into_separate_sync_delegate()
{
// Arrange
var stdOutLinesCount = 0;
var stdErrLinesCount = 0;
void HandleStdOut(string line) => stdOutLinesCount++;
void HandleStdErr(string line) => stdErrLinesCount++;
var cmd =
new Command(Dummy.Program.FilePath)
.WithArguments(["generate", "clob", "--target", "all", "--lines", "100"])
| (HandleStdOut, HandleStdErr);
// Act
await cmd.ExecuteAsync();
// Assert
Assert.Multiple(() =>
{
Assert.That(stdOutLinesCount, Is.EqualTo(100));
Assert.That(stdErrLinesCount, Is.EqualTo(100));
});
}
#endregion
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdout_into_multiple_targets()
{
// Arrange
using var stream1 = new MemoryStream();
using var stream2 = new MemoryStream();
using var stream3 = new MemoryStream();
var target = PipeTarget.Merge(
PipeTarget.ToStream(stream1),
PipeTarget.ToStream(stream2),
PipeTarget.ToStream(stream3)
);
var cmd = new Command(Dummy.Program.FilePath)
.WithArguments(["generate", "blob", "--length", "100000"]) | target;
// Act
await cmd.ExecuteAsync();
// Assert
Assert.Multiple(() =>
{
Assert.That(stream1.Length, Is.EqualTo(100_000));
Assert.That(stream2.Length, Is.EqualTo(100_000));
Assert.That(stream3.Length, Is.EqualTo(100_000));
Assert.That(stream1.ToArray(), Is.EqualTo(stream2.ToArray()));
Assert.That(stream2.ToArray(), Is.EqualTo(stream3.ToArray()));
});
}
[Test]
public async Task I_can_execute_a_command_and_pipe_the_stdout_into_multiple_hierarchical_targets()
{
// Arrange
using var stream1 = new MemoryStream();
using var stream2 = new MemoryStream();
using var stream3 = new MemoryStream();
using var stream4 = new MemoryStream();
var target = PipeTarget.Merge(
PipeTarget.ToStream(stream1),
PipeTarget.Merge(
PipeTarget.ToStream(stream2),
PipeTarget.Merge(
PipeTarget.ToStream(stream3),
PipeTarget.ToStream(stream4))
)
);
var cmd = new Command(Dummy.Program.FilePath)
.WithArguments(["generate", "blob", "--length", "100000"]) | target;
// Act
await cmd.ExecuteAsync();
// Assert
Assert.Multiple(() =>
{
Assert.That(stream1.Length, Is.EqualTo(100_000));
Assert.That(stream2.Length, Is.EqualTo(100_000));
Assert.That(stream3.Length, Is.EqualTo(100_000));
Assert.That(stream4.Length, Is.EqualTo(100_000));
Assert.That(stream1.ToArray(), Is.EqualTo(stream2.ToArray()));
Assert.That(stream2.ToArray(), Is.EqualTo(stream3.ToArray()));
Assert.That(stream3.ToArray(), Is.EqualTo(stream4.ToArray()));
});
}
}

View file

@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<Using Include="NUnit.Framework" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NUnit" />
<PackageReference Include="NUnit3TestAdapter" />
<PackageReference Include="NUnit.Analyzers" />
<PackageReference Include="coverlet.collector" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Process.Tests.Dummy\Process.Tests.Dummy.csproj" />
<ProjectReference Include="..\Process\Process.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,2 @@
[assembly: Parallelizable(ParallelScope.Fixtures)]
// [assembly: LevelOfParallelism(3)]

View file

@ -0,0 +1,65 @@
using Geekeey.Extensions.Process.Buffered;
namespace Geekeey.Extensions.Process.Tests;
[TestFixture]
internal sealed class ValidationTests
{
private static Command Exit() => new Command(Dummy.Program.FilePath)
.WithArguments(["exit", "1"]);
[Test]
public void I_can_try_to_execute_a_command_and_get_an_error_if_it_returns_a_non_zero_exit_code()
{
// Arrange
var cmd = Exit();
// Act & Assert
var exception = Assert.ThrowsAsync<CommandExecutionException>(async () => await cmd.ExecuteAsync());
Assert.Multiple(() =>
{
Assert.That(exception.ExitCode, Is.EqualTo(1));
Assert.That(exception.Message, Does.Contain("a non-zero exit code (1)"));
});
Console.WriteLine(exception.ToString());
}
[Test]
public void I_can_try_to_execute_a_command_with_buffering_and_get_a_detailed_error_if_it_returns_a_non_zero_exit_code()
{
// Arrange
var cmd = Exit();
// Act & Assert
var exception = Assert.ThrowsAsync<CommandExecutionException>(async () => await cmd.ExecuteBufferedAsync());
Assert.Multiple(() =>
{
Assert.That(exception.ExitCode, Is.EqualTo(1));
Assert.That(exception.Message, Does.Contain("Exit code set to 1"));
});
Console.WriteLine(exception.ToString());
}
[Test]
public async Task I_can_execute_a_command_without_validating_the_exit_code()
{
// Arrange
var cmd = Exit()
.WithValidation(CommandExitBehaviour.None);
// Act
var result = await cmd.ExecuteAsync();
// Assert
Assert.Multiple(() =>
{
Assert.That(result.ExitCode, Is.EqualTo(1));
Assert.That(result.IsSuccess, Is.False);
});
}
}

View file

@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,43 @@
using System.Globalization;
using System.Runtime.InteropServices;
namespace Geekeey.Extensions.Process.Win.Notify;
internal static class Interop
{
public delegate bool ConsoleCtrlDelegate(uint dwCtrlEvent);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool FreeConsole();
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool AttachConsole(uint dwProcessId);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool SetConsoleCtrlHandler(ConsoleCtrlDelegate? handlerRoutine, bool add);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool GenerateConsoleCtrlEvent(uint dwCtrlEvent, uint dwProcessGroupId);
}
internal static class Program
{
public static int Main(string[] args)
{
var processId = uint.Parse(args[0], CultureInfo.InvariantCulture);
var signal = uint.Parse(args[1], CultureInfo.InvariantCulture);
// detach from the current console, if one is attached to the process.
Interop.FreeConsole();
var success =
// attach to the target process group
Interop.AttachConsole(processId) &&
// set to ignore signals on self, so the proper exit code can be return
Interop.SetConsoleCtrlHandler(null, true) &&
// send the signal to the process group
Interop.GenerateConsoleCtrlEvent(signal, 0);
return success ? 0 : Marshal.GetLastPInvokeError();
}
}

View file

@ -0,0 +1,15 @@
# Process.Win.Notify
The sending of signals on Windows is difficult so say not the least. If one wishes to send a ctrl signal to a process in
windows, one can't do that. Instead, one can only send a signal to a [process group or console][process-group] . So in
order to use a process group, one must specify the creation of a process group (process tree) when creating a process.
This is currently not possible with the dotnet api ([api-suggestion](https://github.com/dotnet/runtime/issues/44944))
An alternative solution is to use the concept of the [console][process-group] and the fact that
the [`GenerateConsoleCtrlEvent`][generate-console-ctrl-event] can also send a signal to all processes which are
connected to a console when providing the `dwProcessGroupId` with the value `0`.
This project create an executable which detaches its own console and attaches itself to the targeted process and sends
the requested signal to the console.
[process-group]: https://learn.microsoft.com/en-us/windows/console/console-process-groups
[generate-console-ctrl-event]: https://learn.microsoft.com/en-us/windows/console/generateconsolectrlevent

View file

@ -0,0 +1,95 @@
using System.Text;
namespace Geekeey.Extensions.Process.Buffered;
/// <summary>
/// Buffered execution model.
/// </summary>
public static class BufferedCommandExtensions
{
/// <summary>
/// Executes the command asynchronously with buffering.
/// Data written to the standard output and standard error streams is decoded as text
/// and returned as part of the result object.
/// </summary>
/// <remarks>
/// This method can be awaited.
/// </remarks>
public static CommandTask<BufferedCommandResult> ExecuteBufferedAsync(this Command command,
Encoding standardOutputEncoding, Encoding standardErrorEncoding, CancellationToken cancellationToken = default)
{
var stdOutBuffer = new StringBuilder();
var stdErrBuffer = new StringBuilder();
var stdOutPipe = PipeTarget.Merge(
command.StandardOutputPipe,
PipeTarget.ToStringBuilder(stdOutBuffer, standardOutputEncoding)
);
var stdErrPipe = PipeTarget.Merge(
command.StandardErrorPipe,
PipeTarget.ToStringBuilder(stdErrBuffer, standardErrorEncoding)
);
var commandWithPipes = command
.WithStandardOutputPipe(stdOutPipe)
.WithStandardErrorPipe(stdErrPipe);
return commandWithPipes
.ExecuteAsync(cancellationToken)
.Bind(async task =>
{
try
{
var result = await task;
return new BufferedCommandResult(
result.ExitCode,
result.StartTime,
result.ExitTime,
stdOutBuffer.ToString(),
stdErrBuffer.ToString()
);
}
catch (CommandExecutionException exception)
{
var message = $"""
Command execution failed, see the inner exception for details.
Standard error:
{stdErrBuffer.ToString().Trim()}
""";
throw new CommandExecutionException(exception.Command, exception.ExitCode, message, exception);
}
});
}
/// <summary>
/// Executes the command asynchronously with buffering.
/// Data written to the standard output and standard error streams is decoded as text
/// and returned as part of the result object.
/// </summary>
/// <remarks>
/// This method can be awaited.
/// </remarks>
public static CommandTask<BufferedCommandResult> ExecuteBufferedAsync(this Command command,
Encoding encoding, CancellationToken cancellationToken = default)
{
return command.ExecuteBufferedAsync(encoding, encoding, cancellationToken);
}
/// <summary>
/// Executes the command asynchronously with buffering.
/// Data written to the standard output and standard error streams is decoded as text
/// and returned as part of the result object.
/// Uses <see cref="Console.OutputEncoding" /> for decoding.
/// </summary>
/// <remarks>
/// This method can be awaited.
/// </remarks>
public static CommandTask<BufferedCommandResult> ExecuteBufferedAsync(this Command command,
CancellationToken cancellationToken = default)
{
return command.ExecuteBufferedAsync(Console.OutputEncoding, cancellationToken);
}
}

View file

@ -0,0 +1,23 @@
namespace Geekeey.Extensions.Process.Buffered;
/// <summary>
/// Result of a command execution, with buffered text data from standard output and standard error streams.
/// </summary>
public class BufferedCommandResult(
int exitCode,
DateTimeOffset startTime,
DateTimeOffset exitTime,
string standardOutput,
string standardError
) : CommandResult(exitCode, startTime, exitTime)
{
/// <summary>
/// Standard output data produced by the underlying process.
/// </summary>
public string StandardOutput { get; } = standardOutput;
/// <summary>
/// Standard error data produced by the underlying process.
/// </summary>
public string StandardError { get; } = standardError;
}

View file

@ -0,0 +1,137 @@
using System.Globalization;
using System.Text;
namespace Geekeey.Extensions.Process;
/// <summary>
/// Builder that helps format command-line arguments into a string.
/// </summary>
public sealed partial class ArgumentsBuilder
{
private static readonly IFormatProvider DefaultFormatProvider = CultureInfo.InvariantCulture;
private readonly StringBuilder _buffer = new();
/// <summary>
/// Adds the specified value to the list of arguments.
/// </summary>
public ArgumentsBuilder Add(string value, bool escape = true)
{
if (_buffer.Length > 0)
_buffer.Append(' ');
_buffer.Append(escape ? Escape(value) : value);
return this;
}
/// <summary>
/// Adds the specified values to the list of arguments.
/// </summary>
public ArgumentsBuilder Add(IEnumerable<string> values, bool escape = true)
{
foreach (var value in values)
{
Add(value, escape);
}
return this;
}
/// <summary>
/// Adds the specified value to the list of arguments.
/// </summary>
public ArgumentsBuilder Add(IFormattable value, IFormatProvider formatProvider, bool escape = true)
=> Add(value.ToString(null, formatProvider), escape);
/// <summary>
/// Adds the specified value to the list of arguments.
/// The value is converted to string using invariant culture.
/// </summary>
public ArgumentsBuilder Add(IFormattable value, bool escape = true)
=> Add(value, DefaultFormatProvider, escape);
/// <summary>
/// Adds the specified values to the list of arguments.
/// </summary>
public ArgumentsBuilder Add(IEnumerable<IFormattable> values, IFormatProvider formatProvider, bool escape = true)
{
foreach (var value in values)
{
Add(value, formatProvider, escape);
}
return this;
}
/// <summary>
/// Adds the specified values to the list of arguments.
/// The values are converted to string using invariant culture.
/// </summary>
public ArgumentsBuilder Add(IEnumerable<IFormattable> values, bool escape = true)
=> Add(values, DefaultFormatProvider, escape);
/// <summary>
/// Builds the resulting arguments string.
/// </summary>
public string Build() => _buffer.ToString();
}
public partial class ArgumentsBuilder
{
private static string Escape(string argument)
{
// Short circuit if the argument is clean and doesn't need escaping
if (argument.Length > 0 && argument.All(c => !char.IsWhiteSpace(c) && c is not '"'))
return argument;
var buffer = new StringBuilder();
buffer.Append('"');
for (var i = 0; i < argument.Length;)
{
var c = argument[i++];
switch (c)
{
case '\\':
{
var backslashCount = 1;
while (i < argument.Length && argument[i] == '\\')
{
backslashCount++;
i++;
}
if (i == argument.Length)
{
buffer.Append('\\', backslashCount * 2);
}
else if (argument[i] == '"')
{
buffer.Append('\\', backslashCount * 2 + 1).Append('"');
i++;
}
else
{
buffer.Append('\\', backslashCount);
}
break;
}
case '"':
buffer.Append('\\').Append('"');
break;
default:
buffer.Append(c);
break;
}
}
buffer.Append('"');
return buffer.ToString();
}
}

View file

@ -0,0 +1,43 @@
namespace Geekeey.Extensions.Process;
/// <summary>
/// Builder that helps configure environment variables.
/// </summary>
public sealed class EnvironmentVariablesBuilder
{
private readonly Dictionary<string, string?> _vars = new(StringComparer.Ordinal);
/// <summary>
/// Sets an environment variable with the specified name to the specified value.
/// </summary>
public EnvironmentVariablesBuilder Set(string name, string? value)
{
_vars[name] = value;
return this;
}
/// <summary>
/// Sets multiple environment variables from the specified sequence of key-value pairs.
/// </summary>
public EnvironmentVariablesBuilder Set(IEnumerable<KeyValuePair<string, string?>> variables)
{
foreach (var (name, value) in variables)
{
Set(name, value);
}
return this;
}
/// <summary>
/// Sets multiple environment variables from the specified dictionary.
/// </summary>
public EnvironmentVariablesBuilder Set(IReadOnlyDictionary<string, string?> variables)
=> Set((IEnumerable<KeyValuePair<string, string?>>)variables);
/// <summary>
/// Builds the resulting environment variables.
/// </summary>
public IReadOnlyDictionary<string, string?> Build()
=> new Dictionary<string, string?>(_vars, _vars.Comparer);
}

View file

@ -0,0 +1,113 @@
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.Contracts;
namespace Geekeey.Extensions.Process;
public sealed partial class Command
{
/// <summary>
/// Creates a copy of this command, setting the target file path to the specified value.
/// </summary>
[Pure]
public Command WithTargetFile(string targetFilePath)
=> new(targetFilePath, Arguments, WorkingDirPath, EnvironmentVariables, Validation,
StandardInputPipe, StandardOutputPipe, StandardErrorPipe);
/// <summary>
/// Creates a copy of this command, setting the arguments to the specified value.
/// </summary>
/// <remarks>
/// Avoid using this overload, as it requires the arguments to be escaped manually.
/// Formatting errors may lead to unexpected bugs and security vulnerabilities.
/// </remarks>
[Pure]
public Command WithArguments(string arguments)
=> new(TargetFilePath, arguments, WorkingDirPath, EnvironmentVariables, Validation,
StandardInputPipe, StandardOutputPipe, StandardErrorPipe);
/// <summary>
/// Creates a copy of this command, setting the arguments to the value
/// obtained by formatting the specified enumeration.
/// </summary>
[Pure]
public Command WithArguments(IEnumerable<string> arguments, bool escape = true)
=> WithArguments(args => args.Add(arguments, escape));
/// <summary>
/// Creates a copy of this command, setting the arguments to the value
/// configured by the specified delegate.
/// </summary>
[Pure]
public Command WithArguments(Action<ArgumentsBuilder> configure)
{
var builder = new ArgumentsBuilder();
configure(builder);
return WithArguments(builder.Build());
}
/// <summary>
/// Creates a copy of this command, setting the working directory path to the specified value.
/// </summary>
[Pure]
public Command WithWorkingDirectory(string workingDirPath)
=> new(TargetFilePath, Arguments, workingDirPath, EnvironmentVariables, Validation,
StandardInputPipe, StandardOutputPipe, StandardErrorPipe);
/// <summary>
/// Creates a copy of this command, setting the environment variables to the specified value.
/// </summary>
[Pure]
public Command WithEnvironmentVariables(IReadOnlyDictionary<string, string?> environmentVariables)
=> new(TargetFilePath, Arguments, WorkingDirPath, environmentVariables, Validation,
StandardInputPipe, StandardOutputPipe, StandardErrorPipe);
/// <summary>
/// Creates a copy of this command, setting the environment variables to the value
/// configured by the specified delegate.
/// </summary>
[Pure]
public Command WithEnvironmentVariables(Action<EnvironmentVariablesBuilder> configure)
{
var builder = new EnvironmentVariablesBuilder();
configure(builder);
return WithEnvironmentVariables(builder.Build());
}
/// <summary>
/// Creates a copy of this command, setting the validation options to the specified value.
/// </summary>
[Pure]
public Command WithValidation(CommandExitBehaviour exitBehaviour)
=> new(TargetFilePath, Arguments, WorkingDirPath, EnvironmentVariables, exitBehaviour,
StandardInputPipe, StandardOutputPipe, StandardErrorPipe);
/// <summary>
/// Creates a copy of this command, setting the standard input pipe to the specified source.
/// </summary>
[Pure]
public Command WithStandardInputPipe(PipeSource source)
=> new(TargetFilePath, Arguments, WorkingDirPath, EnvironmentVariables, Validation,
source, StandardOutputPipe, StandardErrorPipe);
/// <summary>
/// Creates a copy of this command, setting the standard output pipe to the specified target.
/// </summary>
[Pure]
public Command WithStandardOutputPipe(PipeTarget target)
=> new(TargetFilePath, Arguments, WorkingDirPath, EnvironmentVariables, Validation,
StandardInputPipe, target, StandardErrorPipe);
/// <summary>
/// Creates a copy of this command, setting the standard error pipe to the specified target.
/// </summary>
[Pure]
public Command WithStandardErrorPipe(PipeTarget target)
=> new(TargetFilePath, Arguments, WorkingDirPath, EnvironmentVariables, Validation,
StandardInputPipe, StandardOutputPipe, target);
/// <inheritdoc />
[ExcludeFromCodeCoverage]
public override string ToString() => $"{TargetFilePath} {Arguments}";
}

View file

@ -0,0 +1,197 @@
using System.Diagnostics;
namespace Geekeey.Extensions.Process;
public partial class Command
{
private static readonly Lazy<string?> ProcessPathLazy = new(() =>
{
using var process = System.Diagnostics.Process.GetCurrentProcess();
return process.MainModule?.FileName;
});
private static readonly string[] WindowsExecutableExtensions = ["exe", "cmd", "bat"];
private static readonly TimeSpan CancelWaitTimeout = TimeSpan.FromSeconds(5);
private static string? ProcessPath => ProcessPathLazy.Value;
private ProcessStartInfo CreateStartInfo()
{
var info = new ProcessStartInfo
{
FileName = GetOptimallyQualifiedTargetFilePath(),
Arguments = Arguments,
WorkingDirectory = WorkingDirPath,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
// This option only works on Windows and is required there to prevent the
// child processes from attaching to the parent console window, if one exists.
// We need this in order to be able to send signals to one specific child process,
// without affecting any others that may also be running in parallel.
CreateNoWindow = true
};
// Set environment variables
foreach (var (key, value) in EnvironmentVariables)
{
if (value is not null)
{
info.Environment[key] = value;
}
else
{
// Null value means we should remove the variable
info.Environment.Remove(key);
}
}
return info;
string GetOptimallyQualifiedTargetFilePath()
{
// Currently, we only need this workaround for script files on Windows, so short-circuit
// if we are on a different platform.
if (!OperatingSystem.IsWindows())
return TargetFilePath;
// Don't do anything for fully qualified paths or paths that already have an extension specified.
// System.Diagnostics.Process knows how to handle those without our help.
if (Path.IsPathFullyQualified(TargetFilePath) ||
!string.IsNullOrWhiteSpace(Path.GetExtension(TargetFilePath)))
return TargetFilePath;
return (
from probeDirPath in GetProbeDirectoryPaths()
where Directory.Exists(probeDirPath)
select Path.Combine(probeDirPath, TargetFilePath)
into baseFilePath
from extension in WindowsExecutableExtensions
select Path.ChangeExtension(baseFilePath, extension)
).FirstOrDefault(File.Exists) ?? TargetFilePath;
static IEnumerable<string> GetProbeDirectoryPaths()
{
// Executable directory
if (!string.IsNullOrWhiteSpace(ProcessPath))
{
var processDirPath = Path.GetDirectoryName(ProcessPath);
if (!string.IsNullOrWhiteSpace(processDirPath))
yield return processDirPath;
}
// Working directory
yield return Directory.GetCurrentDirectory();
// Directories on the PATH
if (Environment.GetEnvironmentVariable("PATH")?.Split(Path.PathSeparator) is { } paths)
{
foreach (var path in paths)
{
yield return path;
}
}
}
}
}
/// <summary>
/// Executes the command asynchronously.
/// </summary>
/// <remarks>
/// This method can be awaited.
/// </remarks>
public CommandTask<CommandResult> ExecuteAsync(CancellationToken cancellationToken = default)
{
var process = new ProcessHandle(CreateStartInfo());
process.Start();
// Extract the process ID before calling ExecuteAsync(), because the process may already be disposed by then.
var processId = process.Id;
var task = ExecuteAsync(process, cancellationToken);
return new CommandTask<CommandResult>(task, process, processId);
}
private async Task<CommandResult> ExecuteAsync(ProcessHandle process, CancellationToken cancellationToken = default)
{
using var _ = process;
// Additional cancellation for the stdin pipe in case the process exits without fully exhausting it
using var stdin = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
using var complete = new CancellationTokenSource();
// ReSharper disable once AccessToDisposedClosure
await using (cancellationToken.Register(() => complete.CancelAfter(CancelWaitTimeout)))
await using (cancellationToken.Register(process.Kill))
{
var writing = PipeStdInAsync(process, stdin.Token);
var reading = Task.WhenAll(
PipeStdOutAsync(process, cancellationToken),
PipeStdErrAsync(process, cancellationToken));
// Wait until the process exits normally or gets killed. The timeout is started after the execution is
// forcefully canceled and ensures that we don't wait forever in case the attempt to kill the process failed.
await process.WaitUntilExitAsync(complete.Token);
// Send the cancellation signal to the stdin pipe since the process has exited and won't need it anymore.
// If the pipe has already been exhausted (most likely), this won't do anything. If the pipe is still trying
// to transfer data, this will cause it to abort.
await stdin.CancelAsync();
// Wait until piping is done and propagate exceptions.
await Task.WhenAll(writing, reading);
}
if (process.ExitCode is 0 || !Validation.HasFlag(CommandExitBehaviour.ZeroExitCode))
{
return new CommandResult(process.ExitCode, process.StartTime, process.ExitTime);
}
var message = $"Command execution failed because the underlying process ({process.Name}#{process.Id}) " +
$"returned a non-zero exit code ({process.ExitCode}).";
throw new CommandExecutionException(this, process.ExitCode, message);
}
private async Task PipeStdOutAsync(ProcessHandle process, CancellationToken cancellationToken = default)
{
await using (process.StandardOutput)
{
await StandardOutputPipe.CopyFromAsync(process.StandardOutput, cancellationToken);
}
}
private async Task PipeStdErrAsync(ProcessHandle process, CancellationToken cancellationToken = default)
{
await using (process.StandardError)
{
await StandardErrorPipe.CopyFromAsync(process.StandardError, cancellationToken);
}
}
private async Task PipeStdInAsync(ProcessHandle process, CancellationToken cancellationToken = default)
{
await using (process.StandardInput)
{
try
{
// Some streams do not support cancellation, so we add a fallback that drops the task and returns early.
// This is important with stdin because the process might finish before the pipe has been fully
// exhausted, and we don't want to wait for it.
await StandardInputPipe.CopyToAsync(process.StandardInput, cancellationToken)
.WaitAsync(cancellationToken);
}
// Expect IOException: "The pipe has been ended" (Windows) or "Broken pipe" (Unix). This may happen if the
// process terminated before the pipe has been exhausted. It's not an exceptional situation because the
// process may not need the entire stdin to complete successfully. We also can't rely on process.HasExited
// here because of potential race conditions.
catch (IOException ex) when (ex.GetType() == typeof(IOException))
{
// Don't catch derived exceptions, such as FileNotFoundException, to avoid false positives.
}
}
}
}

View file

@ -0,0 +1,157 @@
using System.Diagnostics.Contracts;
using System.Text;
namespace Geekeey.Extensions.Process;
public partial class Command
{
/// <summary>
/// Creates a new command that pipes its standard output to the specified target.
/// </summary>
[Pure]
public static Command operator |(Command source, PipeTarget target)
=> source.WithStandardOutputPipe(target);
/// <summary>
/// Creates a new command that pipes its standard output to the specified stream.
/// </summary>
[Pure]
public static Command operator |(Command source, Stream target)
=> source | PipeTarget.ToStream(target);
/// <summary>
/// Creates a new command that pipes its standard output to the specified string builder.
/// Uses <see cref="Console.OutputEncoding" /> for decoding.
/// </summary>
[Pure]
public static Command operator |(Command source, StringBuilder target)
=> source | PipeTarget.ToStringBuilder(target, Console.OutputEncoding);
/// <summary>
/// Creates a new command that pipes its standard output line-by-line to the specified
/// asynchronous delegate.
/// Uses <see cref="Console.OutputEncoding" /> for decoding.
/// </summary>
[Pure]
public static Command operator |(Command source, Func<string, CancellationToken, Task> target)
=> source | PipeTarget.ToDelegate(target, Console.OutputEncoding);
/// <summary>
/// Creates a new command that pipes its standard output line-by-line to the specified
/// asynchronous delegate.
/// Uses <see cref="Console.OutputEncoding" /> for decoding.
/// </summary>
[Pure]
public static Command operator |(Command source, Func<string, Task> target)
=> source | PipeTarget.ToDelegate(target, Console.OutputEncoding);
/// <summary>
/// Creates a new command that pipes its standard output line-by-line to the specified
/// synchronous delegate.
/// Uses <see cref="Console.OutputEncoding" /> for decoding.
/// </summary>
[Pure]
public static Command operator |(Command source, Action<string> target)
=> source | PipeTarget.ToDelegate(target, Console.OutputEncoding);
/// <summary>
/// Creates a new command that pipes its standard output and standard error to the
/// specified targets.
/// </summary>
[Pure]
public static Command operator |(Command source, (PipeTarget stdOut, PipeTarget stdErr) targets)
=> source.WithStandardOutputPipe(targets.stdOut).WithStandardErrorPipe(targets.stdErr);
/// <summary>
/// Creates a new command that pipes its standard output and standard error to the
/// specified streams.
/// </summary>
[Pure]
public static Command operator |(Command source, (Stream stdOut, Stream stdErr) targets)
=> source | (PipeTarget.ToStream(targets.stdOut), PipeTarget.ToStream(targets.stdErr));
/// <summary>
/// Creates a new command that pipes its standard output and standard error to the
/// specified string builders.
/// Uses <see cref="Console.OutputEncoding" /> for decoding.
/// </summary>
[Pure]
public static Command operator |(Command source, (StringBuilder stdOut, StringBuilder stdErr) targets)
=> source | (PipeTarget.ToStringBuilder(targets.stdOut, Console.OutputEncoding),
PipeTarget.ToStringBuilder(targets.stdErr, Console.OutputEncoding));
/// <summary>
/// Creates a new command that pipes its standard output and standard error line-by-line
/// to the specified asynchronous delegates.
/// Uses <see cref="Console.OutputEncoding" /> for decoding.
/// </summary>
[Pure]
public static Command operator |(Command source,
(Func<string, CancellationToken, Task> stdOut, Func<string, CancellationToken, Task> stdErr) targets)
=> source | (PipeTarget.ToDelegate(targets.stdOut, Console.OutputEncoding),
PipeTarget.ToDelegate(targets.stdErr, Console.OutputEncoding));
/// <summary>
/// Creates a new command that pipes its standard output and standard error line-by-line
/// to the specified asynchronous delegates.
/// Uses <see cref="Console.OutputEncoding" /> for decoding.
/// </summary>
[Pure]
public static Command operator |(Command source, (Func<string, Task> stdOut, Func<string, Task> stdErr) targets)
=> source | (PipeTarget.ToDelegate(targets.stdOut, Console.OutputEncoding),
PipeTarget.ToDelegate(targets.stdErr, Console.OutputEncoding));
/// <summary>
/// Creates a new command that pipes its standard output and standard error line-by-line
/// to the specified synchronous delegates.
/// Uses <see cref="Console.OutputEncoding" /> for decoding.
/// </summary>
[Pure]
public static Command operator |(Command source, (Action<string> stdOut, Action<string> stdErr) targets)
=> source | (PipeTarget.ToDelegate(targets.stdOut, Console.OutputEncoding),
PipeTarget.ToDelegate(targets.stdErr, Console.OutputEncoding));
/// <summary>
/// Creates a new command that pipes its standard input from the specified source.
/// </summary>
[Pure]
public static Command operator |(PipeSource source, Command target)
=> target.WithStandardInputPipe(source);
/// <summary>
/// Creates a new command that pipes its standard input from the specified stream.
/// </summary>
[Pure]
public static Command operator |(Stream source, Command target)
=> PipeSource.FromStream(source) | target;
/// <summary>
/// Creates a new command that pipes its standard input from the specified memory buffer.
/// </summary>
[Pure]
public static Command operator |(ReadOnlyMemory<byte> source, Command target)
=> PipeSource.FromBytes(source) | target;
/// <summary>
/// Creates a new command that pipes its standard input from the specified byte array.
/// </summary>
[Pure]
public static Command operator |(byte[] source, Command target)
=> PipeSource.FromBytes(source) | target;
/// <summary>
/// Creates a new command that pipes its standard input from the specified string.
/// Uses <see cref="Console.InputEncoding" /> for encoding.
/// </summary>
[Pure]
public static Command operator |(string source, Command target)
=> PipeSource.FromString(source, Console.InputEncoding) | target;
/// <summary>
/// Creates a new command that pipes its standard input from the standard output of the
/// specified command.
/// </summary>
[Pure]
public static Command operator |(Command source, Command target)
=> PipeSource.FromCommand(source) | target;
}

65
src/Process/Command.cs Normal file
View file

@ -0,0 +1,65 @@
namespace Geekeey.Extensions.Process;
/// <summary>
/// Instructions for running a process.
/// </summary>
public sealed partial class Command(
string targetFilePath,
string arguments,
string workingDirPath,
IReadOnlyDictionary<string, string?> environmentVariables,
CommandExitBehaviour validation,
PipeSource standardInputPipe,
PipeTarget standardOutputPipe,
PipeTarget standardErrorPipe
)
{
/// <summary>
/// Initializes an instance of <see cref="Command" />.
/// </summary>
public Command(string targetFilePath) : this(targetFilePath, string.Empty, Directory.GetCurrentDirectory(),
new Dictionary<string, string?>(),
CommandExitBehaviour.ZeroExitCode, PipeSource.Null, PipeTarget.Null, PipeTarget.Null)
{
}
/// <summary>
/// File path of the executable, batch file, or script, that this command runs.
/// </summary>
public string TargetFilePath { get; } = targetFilePath;
/// <summary>
/// File path of the executable, batch file, or script, that this command runs.
/// </summary>
public string Arguments { get; } = arguments;
/// <summary>
/// File path of the executable, batch file, or script, that this command runs.
/// </summary>
public string WorkingDirPath { get; } = workingDirPath;
/// <summary>
/// Environment variables set for the underlying process.
/// </summary>
public IReadOnlyDictionary<string, string?> EnvironmentVariables { get; } = environmentVariables;
/// <summary>
/// Strategy for validating the result of the execution.
/// </summary>
public CommandExitBehaviour Validation { get; } = validation;
/// <summary>
/// Pipe source for the standard input stream of the underlying process.
/// </summary>
public PipeSource StandardInputPipe { get; } = standardInputPipe;
/// <summary>
/// Pipe target for the standard output stream of the underlying process.
/// </summary>
public PipeTarget StandardOutputPipe { get; } = standardOutputPipe;
/// <summary>
/// Pipe target for the standard error stream of the underlying process.
/// </summary>
public PipeTarget StandardErrorPipe { get; } = standardErrorPipe;
}

View file

@ -0,0 +1,23 @@
using Geekeey.Extensions.Process;
namespace Geekeey.Extensions.Process;
/// <summary>
/// Exception thrown when the command fails to execute correctly.
/// </summary>
public class CommandExecutionException(
Command command,
int exitCode,
string message,
Exception? innerException = null) : Exception(message, innerException)
{
/// <summary>
/// Command that triggered the exception.
/// </summary>
public Command Command { get; } = command;
/// <summary>
/// Exit code returned by the process.
/// </summary>
public int ExitCode { get; } = exitCode;
}

View file

@ -0,0 +1,18 @@
namespace Geekeey.Extensions.Process;
/// <summary>
/// Strategy used for validating the result of a command execution.
/// </summary>
[Flags]
public enum CommandExitBehaviour
{
/// <summary>
/// No validation.
/// </summary>
None = 0b0,
/// <summary>
/// Ensure that the command returned a zero exit code.
/// </summary>
ZeroExitCode = 0b1
}

View file

@ -0,0 +1,35 @@
namespace Geekeey.Extensions.Process;
/// <summary>
/// Represents the result of a command execution.
/// </summary>
public class CommandResult(
int exitCode,
DateTimeOffset startTime,
DateTimeOffset exitTime)
{
/// <summary>
/// Exit code set by the underlying process.
/// </summary>
public int ExitCode { get; } = exitCode;
/// <summary>
/// Whether the command execution was successful (i.e. exit code is zero).
/// </summary>
public bool IsSuccess => ExitCode is 0;
/// <summary>
/// Time at which the command started executing.
/// </summary>
public DateTimeOffset StartTime { get; } = startTime;
/// <summary>
/// Time at which the command finished executing.
/// </summary>
public DateTimeOffset ExitTime { get; } = exitTime;
/// <summary>
/// Total duration of the command execution.
/// </summary>
public TimeSpan RunTime => ExitTime - StartTime;
}

View file

@ -0,0 +1,71 @@
using System.Runtime.CompilerServices;
namespace Geekeey.Extensions.Process;
/// <summary>
/// Represents an asynchronous execution of a command.
/// </summary>
public partial class CommandTask<TResult> : IDisposable
{
private readonly ProcessHandle _process;
internal CommandTask(Task<TResult> task, ProcessHandle process, int processId)
{
Task = task;
_process = process;
ProcessId = processId;
}
/// <summary>
/// Underlying task.
/// </summary>
public Task<TResult> Task { get; }
/// <summary>
/// Underlying process ID.
/// </summary>
public int ProcessId { get; }
internal CommandTask<T> Bind<T>(Func<Task<TResult>, Task<T>> transform)
=> new(transform(Task), _process, ProcessId);
/// <summary>
/// Lazily maps the result of the task using the specified transform.
/// </summary>
internal CommandTask<T> Select<T>(Func<TResult, T> transform)
=> Bind(async task => transform(await task));
/// <summary>
/// Signals the process with an interrupt request from the keyboard.
/// </summary>
public void Interrupt() => _process.Interrupt();
/// <summary>
/// Immediately stops the associated process and its descendent processes.
/// </summary>
public void Kill() => _process.Kill();
/// <summary>
/// Gets the awaiter of the underlying task.
/// Used to enable await expressions on this object.
/// </summary>
public TaskAwaiter<TResult> GetAwaiter()
=> Task.GetAwaiter();
/// <summary>
/// Configures an awaiter used to await this task.
/// </summary>
public ConfiguredTaskAwaitable<TResult> ConfigureAwait(bool continueOnCapturedContext)
=> Task.ConfigureAwait(continueOnCapturedContext);
/// <inheritdoc />
public void Dispose() => Task.Dispose();
}
public partial class CommandTask<TResult>
{
/// <summary>
/// Converts the command task into a regular task.
/// </summary>
public static implicit operator Task<TResult>(CommandTask<TResult> commandTask) => commandTask.Task;
}

View file

@ -0,0 +1,30 @@
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
namespace Geekeey.Extensions.Process;
internal partial class ProcessHandle
{
[SupportedOSPlatform("freebsd")]
[SupportedOSPlatform("linux")]
[SupportedOSPlatform("macOS")]
private static bool SendPosixSignal(int pid, PosixSignals signal)
{
return Posix.Kill(pid, (int)signal) is 0;
}
[SupportedOSPlatform("freebsd")]
[SupportedOSPlatform("linux")]
[SupportedOSPlatform("macOS")]
internal static partial class Posix
{
[LibraryImport("libc", EntryPoint = "kill", SetLastError = true)]
internal static partial int Kill(int pid, int sig);
}
private enum PosixSignals : int
{
SIGINT = 2,
SIGTERM = 15
}
}

View file

@ -0,0 +1,79 @@
using System.Diagnostics;
using System.Runtime.Versioning;
using System.Text;
namespace Geekeey.Extensions.Process;
internal partial class ProcessHandle
{
private const string CSharpSourceText =
"""
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool FreeConsole();
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool AttachConsole(uint p);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool SetConsoleCtrlHandler(uint p, bool a);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool GenerateConsoleCtrlEvent(uint e, uint p);
public static int NotifyCtrlEvent(uint pid, uint sig)
{
// detach from the current console, if one is attached to the process.
FreeConsole();
var success =
// attach to the target process group
AttachConsole(pid) &&
// set to ignore signals on self, so the proper exit code can be return
SetConsoleCtrlHandler(0, true) &&
// send the signal to the process group
GenerateConsoleCtrlEvent(sig, 0);
return success ? 0 : Marshal.GetLastWin32Error();
}
""";
private const string PowerShellSourceText =
"""
Add-Type -Namespace 'Win32' -Name 'Notify' -Language CSharp -MemberDefinition '{0}';
exit [Win32.Notify]::NotifyCtrlEvent({1}, {2})
""";
[SupportedOSPlatform("windows")]
private static bool SendCtrlSignal(int processId, ConsoleCtrlEvent ctrl)
{
using var process = new System.Diagnostics.Process();
var text = string.Format(PowerShellSourceText, CSharpSourceText, processId, (uint)ctrl);
text = Convert.ToBase64String(Encoding.Unicode.GetBytes(text));
process.StartInfo = new ProcessStartInfo
{
FileName = "powershell.exe",
Arguments = $"-NoLogo -NoProfile -ExecutionPolicy Bypass -EncodedCommand {text}",
CreateNoWindow = true,
UseShellExecute = false
};
if (!process.Start())
return false;
if (!process.WaitForExit(30_000))
return false;
return process.ExitCode == 0;
}
internal enum ConsoleCtrlEvent
{
CTRL_C_EVENT = 0, // SIGINT
CTRL_BREAK_EVENT = 1, // SIGQUIT
CTRL_CLOSE_EVENT = 2, // SIGHUP
CTRL_LOGOFF_EVENT = 5, // SIGHUP
CTRL_SHUTDOWN_EVENT = 6, // SIGTERM
}
}

View file

@ -0,0 +1,122 @@
using System.ComponentModel;
using System.Diagnostics;
namespace Geekeey.Extensions.Process;
internal sealed partial class ProcessHandle(ProcessStartInfo startInfo) : IDisposable
{
private readonly System.Diagnostics.Process _process = new() { StartInfo = startInfo };
private readonly TaskCompletionSource<object?> _exitTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
public int Id => _process.Id;
// we have to keep track of ProcessName ourselves because it becomes inaccessible after the process exits
public string Name => Path.GetFileName(_process.StartInfo.FileName);
// we are purposely using Stream instead of StreamWriter/StreamReader to push the concerns of
// writing and reading to PipeSource/PipeTarget at the higher level.
public Stream StandardInput => _process.StandardInput.BaseStream;
public Stream StandardOutput => _process.StandardOutput.BaseStream;
public Stream StandardError => _process.StandardError.BaseStream;
// we have to keep track of StartTime ourselves because it becomes inaccessible after the process exits
public DateTimeOffset StartTime { get; private set; }
// we have to keep track of ExitTime ourselves because it becomes inaccessible after the process exits
public DateTimeOffset ExitTime { get; private set; }
public int ExitCode => _process.ExitCode;
public void Start()
{
_process.EnableRaisingEvents = true;
_process.Exited += (_, _) =>
{
ExitTime = DateTimeOffset.Now;
_exitTcs.TrySetResult(null);
};
try
{
if (!_process.Start())
{
throw new InvalidOperationException(
$"Failed to start a process with file path '{_process.StartInfo.FileName}'. " +
$"Target file is not an executable or lacks execute permissions.");
}
StartTime = DateTimeOffset.Now;
}
catch (Win32Exception exception)
{
throw new Win32Exception(
$"Failed to start a process with file path '{_process.StartInfo.FileName}'. " +
$"Target file or working directory doesn't exist, or the provided credentials are invalid.", exception);
}
}
public void Interrupt()
{
if (TryInterrupt()) return;
// In case of failure, revert to the default behavior of killing the process.
// Ideally, we should throw an exception here, but this method is called from
// a cancellation callback, which would prevent other callbacks from being called.
Kill();
Debug.Fail("Failed to send an interrupt signal.");
return;
bool TryInterrupt()
{
try
{
if (OperatingSystem.IsWindows())
{
return SendCtrlSignal(_process.Id, ConsoleCtrlEvent.CTRL_C_EVENT) ||
SendCtrlSignal(_process.Id, ConsoleCtrlEvent.CTRL_BREAK_EVENT);
}
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS() || OperatingSystem.IsFreeBSD())
{
return SendPosixSignal(_process.Id, PosixSignals.SIGINT) ||
SendPosixSignal(_process.Id, PosixSignals.SIGTERM);
}
// Unsupported platform
return false;
}
catch
{
return false;
}
}
}
public void Kill()
{
try
{
_process.Kill(true);
}
catch when (_process.HasExited)
{
// The process has exited before we could kill it. This is fine.
}
catch
{
// The process either failed to exit or is in the process of exiting.
// We can't really do anything about it, so just ignore the exception.
Debug.Fail("Failed to kill the process.");
}
}
public async Task WaitUntilExitAsync(CancellationToken cancellationToken = default)
{
await _exitTcs.Task.WaitAsync(cancellationToken);
}
public void Dispose() => _process.Dispose();
}

View file

@ -0,0 +1,7 @@
namespace Geekeey.Extensions.Process;
internal static class BufferSizes
{
public const int Stream = 81920;
public const int StreamReader = 1024;
}

View file

@ -0,0 +1,112 @@
using System.Buffers;
using System.Diagnostics.CodeAnalysis;
namespace Geekeey.Extensions.Process;
internal sealed class MemoryBufferStream : Stream
{
private readonly SemaphoreSlim _writeLock = new(1, 1);
private readonly SemaphoreSlim _readLock = new(0, 1);
private IMemoryOwner<byte> _sharedBuffer = MemoryPool<byte>.Shared.Rent(BufferSizes.Stream);
private int _sharedBufferBytes;
private int _sharedBufferBytesRead;
[ExcludeFromCodeCoverage] public override bool CanRead => true;
[ExcludeFromCodeCoverage] public override bool CanSeek => false;
[ExcludeFromCodeCoverage] public override bool CanWrite => true;
[ExcludeFromCodeCoverage] public override long Position { get; set; }
[ExcludeFromCodeCoverage] public override long Length => throw new NotSupportedException();
[ExcludeFromCodeCoverage]
public override void Write(byte[] buffer, int offset, int count)
=> WriteAsync(buffer, offset, count).GetAwaiter().GetResult();
[ExcludeFromCodeCoverage]
public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
=> await WriteAsync(buffer.AsMemory(offset, count), cancellationToken);
public override async ValueTask WriteAsync(ReadOnlyMemory<byte> buffer,
CancellationToken cancellationToken = default)
{
await _writeLock.WaitAsync(cancellationToken);
// Reset the buffer if the current one is too small for the incoming data
if (_sharedBuffer.Memory.Length < buffer.Length)
{
_sharedBuffer.Dispose();
_sharedBuffer = MemoryPool<byte>.Shared.Rent(buffer.Length);
}
buffer.CopyTo(_sharedBuffer.Memory);
_sharedBufferBytes = buffer.Length;
_sharedBufferBytesRead = 0;
_readLock.Release();
}
[ExcludeFromCodeCoverage]
public override int Read(byte[] buffer, int offset, int count)
=> ReadAsync(buffer, offset, count).GetAwaiter().GetResult();
[ExcludeFromCodeCoverage]
public override async Task<int>
ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) =>
await ReadAsync(buffer.AsMemory(offset, count), cancellationToken);
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
await _readLock.WaitAsync(cancellationToken);
var length = Math.Min(buffer.Length, _sharedBufferBytes - _sharedBufferBytesRead);
_sharedBuffer.Memory.Slice(_sharedBufferBytesRead, length).CopyTo(buffer);
_sharedBufferBytesRead += length;
// release the write lock if the consumer has finished reading all
// the previously written data.
if (_sharedBufferBytesRead >= _sharedBufferBytes)
{
_writeLock.Release();
}
// otherwise, release the read lock again so that the consumer can finish
// reading the data.
else
{
_readLock.Release();
}
return length;
}
public async Task ReportCompletionAsync(CancellationToken cancellationToken = default) =>
// write an empty buffer that will make ReadAsync(...) return 0, which signals the end of stream
await WriteAsync(Memory<byte>.Empty, cancellationToken);
protected override void Dispose(bool disposing)
{
if (disposing)
{
_readLock.Dispose();
_writeLock.Dispose();
_sharedBuffer.Dispose();
}
base.Dispose(disposing);
}
[ExcludeFromCodeCoverage]
public override void Flush() => throw new NotSupportedException();
[ExcludeFromCodeCoverage]
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
[ExcludeFromCodeCoverage]
public override void SetLength(long value) => throw new NotSupportedException();
}

102
src/Process/PipeSource.cs Normal file
View file

@ -0,0 +1,102 @@
using System.Text;
namespace Geekeey.Extensions.Process;
/// <summary>
/// Represents a pipe for the process's standard input stream.
/// </summary>
public abstract partial class PipeSource
{
/// <summary>
/// Reads the binary content pushed into the pipe and writes it to the destination stream.
/// Destination stream represents the process's standard input stream.
/// </summary>
public abstract Task CopyToAsync(Stream destination, CancellationToken cancellationToken = default);
}
public partial class PipeSource
{
private class AnonymousPipeSource(Func<Stream, CancellationToken, Task> func) : PipeSource
{
public override async Task CopyToAsync(Stream destination, CancellationToken cancellationToken = default)
=> await func(destination, cancellationToken);
}
}
public partial class PipeSource
{
/// <summary>
/// Pipe source that does not provide any data.
/// Functionally equivalent to a null device.
/// </summary>
public static PipeSource Null { get; } = Create((_, cancellationToken)
=> !cancellationToken.IsCancellationRequested ? Task.CompletedTask : Task.FromCanceled(cancellationToken));
/// <summary>
/// Creates an anonymous pipe source with the <see cref="CopyToAsync(Stream, CancellationToken)" /> method
/// implemented by the specified asynchronous delegate.
/// </summary>
public static PipeSource Create(Func<Stream, CancellationToken, Task> func)
=> new AnonymousPipeSource(func);
/// <summary>
/// Creates an anonymous pipe source with the <see cref="CopyToAsync(Stream, CancellationToken)" /> method
/// implemented by the specified synchronous delegate.
/// </summary>
public static PipeSource Create(Action<Stream> action) => Create(
(destination, _) =>
{
action(destination);
return Task.CompletedTask;
});
/// <summary>
/// Creates a pipe source that reads from the specified stream.
/// </summary>
public static PipeSource FromStream(Stream stream) => Create(
async (destination, cancellationToken) =>
await stream.CopyToAsync(destination, cancellationToken));
/// <summary>
/// Creates a pipe source that reads from the specified file.
/// </summary>
public static PipeSource FromFile(string filePath) => Create(
async (destination, cancellationToken) =>
{
await using var source = File.OpenRead(filePath);
await source.CopyToAsync(destination, cancellationToken);
});
/// <summary>
/// Creates a pipe source that reads from the specified memory buffer.
/// </summary>
public static PipeSource FromBytes(ReadOnlyMemory<byte> data) => Create(
async (destination, cancellationToken) =>
await destination.WriteAsync(data, cancellationToken));
/// <summary>
/// Creates a pipe source that reads from the specified byte array.
/// </summary>
public static PipeSource FromBytes(byte[] data)
=> FromBytes((ReadOnlyMemory<byte>)data);
/// <summary>
/// Creates a pipe source that reads from the specified string.
/// </summary>
public static PipeSource FromString(string str, Encoding encoding)
=> FromBytes(encoding.GetBytes(str));
/// <summary>
/// Creates a pipe source that reads from the specified string.
/// Uses <see cref="Console.InputEncoding" /> for encoding.
/// </summary>
public static PipeSource FromString(string str)
=> FromString(str, Console.InputEncoding);
/// <summary>
/// Creates a pipe source that reads from the standard output of the specified command.
/// </summary>
public static PipeSource FromCommand(Command command) => Create(
async (destination, cancellationToken) =>
await command.WithStandardOutputPipe(PipeTarget.ToStream(destination)).ExecuteAsync(cancellationToken));
}

280
src/Process/PipeTarget.cs Normal file
View file

@ -0,0 +1,280 @@
using System.Buffers;
using System.Text;
namespace Geekeey.Extensions.Process;
/// <summary>
/// Represents a pipe for the process's standard output or standard error stream.
/// </summary>
public abstract partial class PipeTarget
{
/// <summary>
/// Reads the binary content from the origin stream and pushes it into the pipe.
/// Origin stream represents the process's standard output or standard error stream.
/// </summary>
public abstract Task CopyFromAsync(Stream origin, CancellationToken cancellationToken = default);
}
public partial class PipeTarget
{
private class AnonymousPipeTarget(Func<Stream, CancellationToken, Task> func) : PipeTarget
{
public override async Task CopyFromAsync(Stream origin, CancellationToken cancellationToken = default)
=> await func(origin, cancellationToken);
}
private class AggregatePipeTarget(IReadOnlyList<PipeTarget> targets) : PipeTarget
{
public IReadOnlyList<PipeTarget> Targets { get; } = targets;
public override async Task CopyFromAsync(Stream origin, CancellationToken cancellationToken = default)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
// create a separate sub-stream for each target
var targetSubStreams = new Dictionary<PipeTarget, MemoryBufferStream>();
foreach (var target in Targets)
{
targetSubStreams[target] = new MemoryBufferStream();
}
try
{
// start piping in the background
async Task StartCopyAsync(KeyValuePair<PipeTarget, MemoryBufferStream> targetSubStream)
{
var (target, subStream) = targetSubStream;
try
{
// ReSharper disable once AccessToDisposedClosure
await target.CopyFromAsync(subStream, cts.Token);
}
catch
{
// abort the operation if any of the targets fail
// ReSharper disable once AccessToDisposedClosure
await cts.CancelAsync();
throw;
}
}
var readingTask = Task.WhenAll(targetSubStreams.Select(StartCopyAsync));
try
{
// read from the main stream and replicate the data to each sub-stream
using var buffer = MemoryPool<byte>.Shared.Rent(BufferSizes.Stream);
while (true)
{
var bytesRead = await origin.ReadAsync(buffer.Memory, cts.Token);
if (bytesRead <= 0)
break;
foreach (var (_, subStream) in targetSubStreams)
{
await subStream.WriteAsync(buffer.Memory[..bytesRead], cts.Token);
}
}
// report that transmission is complete
foreach (var (_, subStream) in targetSubStreams)
{
await subStream.ReportCompletionAsync(cts.Token);
}
}
finally
{
// wait for all targets to finish and maybe propagate exceptions
await readingTask;
}
}
finally
{
foreach (var (_, stream) in targetSubStreams)
{
await stream.DisposeAsync();
}
}
}
}
}
public partial class PipeTarget
{
/// <summary>
/// Pipe target that discards all data. Functionally equivalent to a null device.
/// </summary>
/// <remarks>
/// Using this target results in the corresponding stream (standard output or standard error) not being opened for
/// the underlying process at all. In the vast majority of cases, this behavior should be functionally equivalent to
/// piping to a null stream, but without the performance overhead of consuming and discarding unneeded data. This
/// may be undesirable in certain situations, in which case it's recommended to pipe to a null stream explicitly
/// using <see cref="ToStream(Stream)" /> with <see cref="Stream.Null" />.
/// </remarks>
public static PipeTarget Null { get; } = Create((_, cancellationToken) =>
!cancellationToken.IsCancellationRequested ? Task.CompletedTask : Task.FromCanceled(cancellationToken));
/// <summary>
/// Creates an anonymous pipe target with the <see cref="CopyFromAsync(Stream, CancellationToken)" /> method
/// implemented by the specified asynchronous delegate.
/// </summary>
public static PipeTarget Create(Func<Stream, CancellationToken, Task> func)
=> new AnonymousPipeTarget(func);
/// <summary>
/// Creates an anonymous pipe target with the <see cref="CopyFromAsync(Stream, CancellationToken)" /> method
/// implemented by the specified synchronous delegate.
/// </summary>
public static PipeTarget Create(Action<Stream> action) => Create(
(origin, _) =>
{
action(origin);
return Task.CompletedTask;
});
/// <summary>
/// Creates a pipe target that writes to the specified stream.
/// </summary>
public static PipeTarget ToStream(Stream stream) => Create(
async (origin, cancellationToken) =>
await origin.CopyToAsync(stream, cancellationToken));
/// <summary>
/// Creates a pipe target that writes to the specified file.
/// </summary>
public static PipeTarget ToFile(string filePath) => Create(
async (origin, cancellationToken) =>
{
await using var target = File.Create(filePath);
await origin.CopyToAsync(target, cancellationToken);
});
/// <summary>
/// Creates a pipe target that writes to the specified string builder.
/// </summary>
public static PipeTarget ToStringBuilder(StringBuilder stringBuilder, Encoding encoding) => Create(
async (origin, cancellationToken) =>
{
using var reader = new StreamReader(origin, encoding, false, BufferSizes.StreamReader, true);
using var buffer = MemoryPool<char>.Shared.Rent(BufferSizes.StreamReader);
while (!cancellationToken.IsCancellationRequested)
{
var charsRead = await reader.ReadAsync(buffer.Memory, cancellationToken);
if (charsRead <= 0) break;
stringBuilder.Append(buffer.Memory[..charsRead]);
}
});
/// <summary>
/// Creates a pipe target that writes to the specified string builder.
/// Uses <see cref="Console.OutputEncoding" /> for decoding.
/// </summary>
public static PipeTarget ToStringBuilder(StringBuilder stringBuilder)
=> ToStringBuilder(stringBuilder, Console.OutputEncoding);
/// <summary>
/// Creates a pipe target that invokes the specified asynchronous delegate on every line written to the stream.
/// </summary>
public static PipeTarget ToDelegate(Func<string, CancellationToken, Task> func, Encoding encoding) => Create(
async (origin, cancellationToken) =>
{
using var reader = new StreamReader(origin, encoding, false, BufferSizes.StreamReader, true);
while (await reader.ReadLineAsync(cancellationToken) is { } line)
{
await func(line, cancellationToken);
}
});
/// <summary>
/// Creates a pipe target that invokes the specified asynchronous delegate on every line written to the stream.
/// Uses <see cref="Console.OutputEncoding" /> for decoding.
/// </summary>
public static PipeTarget ToDelegate(Func<string, CancellationToken, Task> func) =>
ToDelegate(func, Console.OutputEncoding);
/// <summary>
/// Creates a pipe target that invokes the specified asynchronous delegate on every line written to the stream.
/// </summary>
public static PipeTarget ToDelegate(Func<string, Task> func, Encoding encoding) => ToDelegate(
async (line, _) => await func(line), encoding);
/// <summary>
/// Creates a pipe target that invokes the specified asynchronous delegate on every line written to the stream.
/// Uses <see cref="Console.OutputEncoding" /> for decoding.
/// </summary>
public static PipeTarget ToDelegate(Func<string, Task> func) => ToDelegate(func, Console.OutputEncoding);
/// <summary>
/// Creates a pipe target that invokes the specified synchronous delegate on every line written to the stream.
/// </summary>
public static PipeTarget ToDelegate(Action<string> action, Encoding encoding) => ToDelegate(
line =>
{
action(line);
return Task.CompletedTask;
}, encoding);
/// <summary>
/// Creates a pipe target that invokes the specified synchronous delegate on every line written to the stream.
/// Uses <see cref="Console.OutputEncoding" /> for decoding.
/// </summary>
public static PipeTarget ToDelegate(Action<string> action)
=> ToDelegate(action, Console.OutputEncoding);
/// <summary>
/// Creates a pipe target that replicates data over multiple inner targets.
/// </summary>
public static PipeTarget Merge(IEnumerable<PipeTarget> targets)
{
// optimize targets to avoid unnecessary piping
var optimizedTargets = OptimizeTargets(targets);
return optimizedTargets.Count switch
{
// avoid merging if there are no targets
0 => Null,
// avoid merging if there's only one target
1 => optimizedTargets.Single(),
_ => new AggregatePipeTarget(optimizedTargets)
};
static IReadOnlyList<PipeTarget> OptimizeTargets(IEnumerable<PipeTarget> targets)
{
var result = new List<PipeTarget>();
// unwrap merged targets
UnwrapTargets(targets, result);
// filter out no-op
result.RemoveAll(t => t == Null);
return result;
}
static void UnwrapTargets(IEnumerable<PipeTarget> targets, ICollection<PipeTarget> output)
{
foreach (var target in targets)
{
if (target is AggregatePipeTarget mergedTarget)
{
UnwrapTargets(mergedTarget.Targets, output);
}
else
{
output.Add(target);
}
}
}
}
/// <summary>
/// Creates a pipe target that replicates data over multiple inner targets.
/// </summary>
public static PipeTarget Merge(params PipeTarget[] targets) => Merge((IEnumerable<PipeTarget>)targets);
}

18
src/Process/Prelude.cs Normal file
View file

@ -0,0 +1,18 @@
namespace Geekeey.Extensions.Process;
/// <summary>
/// A class containing various utility methods, a 'prelude' to the rest of the library.
/// </summary>
/// <remarks>
/// This class is meant to be imported statically, e.g. <c>using static Geekeey.Extensions.Process.Prelude;</c>.
/// Recommended to be imported globally via a global using statement.
/// </remarks>
public static class Prelude
{
/// <summary>
/// Creates a new command that targets the specified command-line executable, batch file, or script.
/// </summary>
/// <param name="targetFilePath">The name of the file to be executed.</param>
/// <returns>A command representing the parameters used to execute the executable.</returns>
public static Command Run(string targetFilePath) => new(targetFilePath);
}

View file

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<PropertyGroup>
<!-- required because of native library import for libc -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="$(AssemblyName).Tests" />
</ItemGroup>
<ItemGroup>
<None Include=".\package-readme.md" Pack="true" PackagePath="\" />
<None Include="Project.props" Pack="true" PackagePath="build\$(PackageId).props" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,5 @@
<Project>
<ItemGroup Condition="'$(ImplicitUsings)' == 'enable'">
<Using Include="Geekeey.Extensions.Process.Prelude" Static="true" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,2 @@
Process is a library for interacting with external command-line interfaces. It provides a convenient model for launching
processes, redirecting input and output streams, awaiting completion, handling cancellation, and more.