feat: initial project commit
All checks were successful
default / default (8.0) (push) Successful in 39s

This commit is contained in:
Louis Seubert 2024-04-27 20:15:05 +02:00
commit 833bc2bd9c
Signed by: louis9902
GPG key ID: 4B9DB28F826553BD
52 changed files with 3783 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,36 @@

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
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
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
EndGlobalSection
EndGlobal

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,25 @@
using Spectre.Console.Cli;
namespace Geekeey.Extensions.Process.Tests.Dummy.Commands;
internal sealed class EchoCommand : Command<EchoCommand.Settings>
{
public sealed class Settings : CommandSettings
{
[CommandOption("--target")] public OutputTarget Target { get; init; } = OutputTarget.StdOut;
[CommandOption("--separator <sep>")] public string Separator { get; init; } = " ";
[CommandArgument(0, "[line]")] public string[] Items { get; init; } = [];
}
public override int Execute(CommandContext context, Settings settings)
{
foreach (var writer in settings.Target.GetWriters())
{
writer.WriteLine(string.Join(settings.Separator, settings.Items));
}
return 0;
}
}

View file

@ -0,0 +1,39 @@
using System.Buffers;
using Spectre.Console.Cli;
namespace Geekeey.Extensions.Process.Tests.Dummy.Commands;
internal sealed class EchoStdinCommand : Command<EchoStdinCommand.Settings>
{
public sealed class Settings : CommandSettings
{
[CommandOption("--target")] public OutputTarget Target { get; init; } = OutputTarget.StdOut;
[CommandOption("--length")] public long Length { get; init; } = long.MaxValue;
}
public override int Execute(CommandContext context, Settings settings)
{
using var buffer = MemoryPool<char>.Shared.Rent(81920);
var totalBytesRead = 0L;
while (totalBytesRead < settings.Length)
{
var bytesWanted = (int)Math.Min(buffer.Memory.Length, settings.Length - totalBytesRead);
var bytesRead = Console.In.Read(buffer.Memory.Span[..bytesWanted]);
if (bytesRead <= 0)
break;
foreach (var writer in settings.Target.GetWriters())
{
writer.Write(buffer.Memory.Span[..bytesRead]);
}
totalBytesRead += bytesRead;
}
return 0;
}
}

View file

@ -0,0 +1,21 @@
using Spectre.Console.Cli;
namespace Geekeey.Extensions.Process.Tests.Dummy.Commands;
internal sealed class EnvironmentCommand : Command<EnvironmentCommand.Settings>
{
public sealed class Settings : CommandSettings
{
[CommandArgument(0, "<ARGUMENT>")] public string[] Variables { get; init; } = [];
}
public override int Execute(CommandContext context, Settings settings)
{
foreach (var name in settings.Variables)
{
Console.Out.WriteLine(Environment.GetEnvironmentVariable(name) ?? string.Empty);
}
return 0;
}
}

View file

@ -0,0 +1,17 @@
using Spectre.Console.Cli;
namespace Geekeey.Extensions.Process.Tests.Dummy.Commands;
internal sealed class ExitCommand : Command<ExitCommand.Settings>
{
public sealed class Settings : CommandSettings
{
[CommandArgument(1, "<code>")] public int Code { get; init; }
}
public override int Execute(CommandContext context, Settings settings)
{
Console.Error.WriteLine($"Exit code set to {settings.Code}");
return settings.Code;
}
}

View file

@ -0,0 +1,16 @@
using Spectre.Console.Cli;
namespace Geekeey.Extensions.Process.Tests.Dummy.Commands;
internal sealed class WorkingDirectoryCommand : Command<WorkingDirectoryCommand.Settings>
{
public sealed class Settings : CommandSettings
{
}
public override int Execute(CommandContext context, Settings settings)
{
Console.Out.WriteLine(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<TextWriter> GetWriters(this OutputTarget target)
{
if (target.HasFlag(OutputTarget.StdOut))
yield return Console.Out;
if (target.HasFlag(OutputTarget.StdErr))
yield return Console.Error;
}
}

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,36 @@
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<ExitCommand>("exit");
}
}
}

View file

@ -0,0 +1,68 @@
using Geekeey.Extensions.Process.Buffered;
using Geekeey.Extensions.Process.Tests.Dummy;
namespace Geekeey.Extensions.Process.Tests;
[TestFixture]
internal sealed class BufferedExecuteTests
{
[Test]
public async Task ExecuteBuffered_Stdout()
{
// Arrange
var cmd = new Command(Program.FilePath)
.WithArguments(["echo", "--target", "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 ExecuteBuffered_Stderr()
{
// Arrange
var cmd = new Command(Program.FilePath)
.WithArguments(["echo", "--target", "stderr", "Hello stdout",]);
// 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 stdout"));
});
}
[Test]
public async Task ExecuteBuffered_Stdout_And_Stderr()
{
// Arrange
var cmd = new Command(Program.FilePath)
.WithArguments(["echo", "--target", "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"));
});
}
}

View file

@ -0,0 +1,271 @@
namespace Geekeey.Extensions.Process.Tests;
[TestFixture]
internal sealed class CommandTests
{
[Test]
public void New_Command_Defaults()
{
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 New_Command_WithTargetFile()
{
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 New_Command_WithArguments()
{
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 New_Command_WithArguments_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 New_Command_WithArguments_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 New_Command_WithWorkingDirectory()
{
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 New_Command_WithEnvironmentVariables()
{
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 New_Command_WithEnvironmentVariables_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 New_Command_WithValidation()
{
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 New_Command_WithStandardInputPipe()
{
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 New_Command_WithStandardOutputPipe()
{
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 New_Command_WithStandardErrorPipe()
{
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,139 @@
using System.ComponentModel;
using Geekeey.Extensions.Process.Buffered;
using Geekeey.Extensions.Process.Tests.Dummy;
namespace Geekeey.Extensions.Process.Tests;
[TestFixture]
internal sealed class ExecuteTests
{
[Test]
public async Task Execute_ExitCode_And_RunTime()
{
// Arrange
var cmd = new Command(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 Execute_ProcessId()
{
// Arrange
var cmd = new Command(Program.FilePath)
.WithArguments(["echo"]);
// Act
var task = cmd.ExecuteAsync();
// Assert
Assert.That(task.ProcessId, Is.Not.EqualTo(0));
await task;
}
[Test]
public async Task Execute_WithAwaiter()
{
// Arrange
var cmd = new Command(Program.FilePath)
.WithArguments(["echo"]);
// Act + Assert
await cmd.ExecuteAsync().ConfigureAwait(false);
}
[Test]
public Task Execute_Throw_On_FileNotFound()
{
// 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 Execute_WithWorkingDirectory()
{
// Arrange
using var dir = TestTempDirectory.Create();
var cmd = new Command(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 Execute_WithEnvironmentVariables()
{
// Arrange
var cmd = new Command(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 Execute_WithEnvironmentVariables_Overrides()
{
// 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")); // will be left unchanged
using var _b = (TestEnvironment.Create(variableToOverwrite, "overwrite")); // will be overwritten
using var _c = (TestEnvironment.Create(variableToUnset, "unset")); // will be unset
var cmd = new Command(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,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,48 @@
using Geekeey.Extensions.Process.Buffered;
namespace Geekeey.Extensions.Process.Tests;
[TestFixture]
internal sealed class PathResolutionTests
{
[Test]
public async Task Execute_CommandUsingShortName()
{
// 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 Execute_ScriptUsingShortName()
{
// 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,127 @@
using Geekeey.Extensions.Process.Buffered;
namespace Geekeey.Extensions.Process.Tests;
[TestFixture]
internal sealed class PipingTests
{
[Test]
public async Task Pipe_StdinFromAsyncAnonymousSource()
{
// 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 Pipe_StdinFromSyncAnonymousSource()
{
// 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 Pipe_StdinFromStream()
{
// 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 Pipe_StdinFromMemory()
{
// 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 Pipe_StdinFromByteArray()
{
// 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 Pipe_StdinFromString()
{
// 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 Pipe_NoStdinToProgramWhichExpectsInput()
{
// Arrange
var cmd = new Command(Dummy.Program.FilePath)
.WithArguments("echo-stdin");
// Act
await cmd.ExecuteAsync();
}
[Test]
public async Task Pipe_EmptyDataToProgramWhichExpectsInput()
{
// Arrange
var cmd = Array.Empty<byte>() | new Command(Dummy.Program.FilePath)
.WithArguments("echo-stdin");
// Act
await cmd.ExecuteAsync();
}
}

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,60 @@
using Geekeey.Extensions.Process.Buffered;
namespace Geekeey.Extensions.Process.Tests;
[TestFixture]
internal sealed class ValidationTests
{
[Test]
public void Execute_Throws_OnNonZeroExit()
{
// Arrange
var cmd = new Command(Dummy.Program.FilePath)
.WithArguments(["exit", "1"]);
// 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)"));
});
}
[Test]
public void ExecuteBuffered_Throws_OnNonZeroExit()
{
// Arrange
var cmd = new Command(Dummy.Program.FilePath)
.WithArguments(["exit", "1"]);
// 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"));
});
}
[Test]
public async Task Execute_DoesNothingOnNone()
{
// Arrange
var cmd = new Command(Dummy.Program.FilePath)
.WithValidation(CommandExitBehaviour.None)
.WithArguments(["exit", "1"]);
// Act
var exception = await cmd.ExecuteAsync();
// Assert
Assert.Multiple(() =>
{
Assert.That(exception.ExitCode, Is.EqualTo(1));
Assert.That(exception.IsSuccess, Is.False);
});
}
}

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,212 @@
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, 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));
try
{
// 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);
}
catch (OperationCanceledException exception) when (ShouldSwallowException(exception))
{
}
bool ShouldSwallowException(OperationCanceledException exception)
=> exception.CancellationToken == cancellationToken ||
exception.CancellationToken == complete.Token ||
exception.CancellationToken == stdin.Token;
}
if (process.ExitCode is not 0 && Validation.HasFlag(CommandExitBehaviour.ZeroExitCode))
{
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);
}
return new CommandResult(process.ExitCode, process.StartTime, process.ExitTime);
}
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,145 @@
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 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,58 @@
using System.Runtime.CompilerServices;
namespace Geekeey.Extensions.Process;
/// <summary>
/// Represents an asynchronous execution of a command.
/// </summary>
public partial class CommandTask<TResult> : IDisposable
{
internal CommandTask(Task<TResult> task, int processId)
{
Task = task;
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), 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>
/// 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 bool SendPosixSignal(PosixSignals signal)
{
return Posix.Kill(Id, (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,19 @@
using System.Runtime.Versioning;
namespace Geekeey.Extensions.Process;
internal partial class ProcessHandle
{
[SupportedOSPlatform("windows")]
private bool SendCtrlSignal(ConsoleCtrlEvent signal)
{
// TODO: find a way to implement this correctly
return false;
}
private enum ConsoleCtrlEvent
{
CTRL_C = 0,
CTRL_BREAK = 1
}
}

View file

@ -0,0 +1,113 @@
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()
{
try
{
if (OperatingSystem.IsWindows())
{
if (SendCtrlSignal(ConsoleCtrlEvent.CTRL_C))
return;
if (SendCtrlSignal(ConsoleCtrlEvent.CTRL_BREAK))
return;
}
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS() || OperatingSystem.IsFreeBSD())
{
if (SendPosixSignal(PosixSignals.SIGINT))
return;
if (SendPosixSignal(PosixSignals.SIGTERM))
return;
}
// Unsupported platform
}
catch
{
}
}
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\$(AssemblyName).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.