build: initial project release

This commit is contained in:
Louis Seubert 2026-01-20 22:41:16 +01:00
commit 267571ccc1
Signed by: louis9902
GPG key ID: 4B9DB28F826553BD
60 changed files with 4935 additions and 0 deletions

419
.editorconfig Normal file
View file

@ -0,0 +1,419 @@
root = true
[*]
indent_style = tab
indent_size = 4
tab_width = 4
end_of_line = lf
insert_final_newline = false
trim_trailing_whitespace = true
max_line_length = 120
[*.{md,json,yaml,yml}]
indent_size = 2
indent_style = space
trim_trailing_whitespace = false
[*.{csproj,props,targets,slnx}]
indent_size = 2
indent_style = space
[nuget.config]
indent_size = 2
indent_style = space
#### .NET Coding Conventions ####
[*.{cs,vb}]
# Organize usings
file_header_template = Copyright (c) The Geekeey Authors\nSPDX-License-Identifier: EUPL-1.2
dotnet_separate_import_directive_groups = true
dotnet_sort_system_directives_first = true
dotnet_diagnostic.IDE0005.severity = suggestion # https://github.com/dotnet/roslyn/issues/41640
# this. and Me. preferences
dotnet_style_qualification_for_event = false
dotnet_style_qualification_for_field = false
dotnet_style_qualification_for_method = false
dotnet_style_qualification_for_property = false
# Language keywords vs BCL types preferences
dotnet_style_predefined_type_for_locals_parameters_members = true
dotnet_style_predefined_type_for_member_access = true
# Parentheses preferences
dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity
dotnet_style_parentheses_in_other_binary_operators = always_for_clarity
dotnet_style_parentheses_in_other_operators = never_if_unnecessary
dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity
# Modifier preferences
dotnet_style_require_accessibility_modifiers = for_non_interface_members
# Expression-level preferences
dotnet_diagnostic.IDE0270.severity = none
dotnet_style_coalesce_expression = true # IDE0029,IDE0030,IDE0270
dotnet_style_collection_initializer = true
dotnet_style_explicit_tuple_names = true
dotnet_style_null_propagation = true
dotnet_style_object_initializer = true
dotnet_style_operator_placement_when_wrapping = beginning_of_line
dotnet_style_prefer_auto_properties = true
dotnet_style_prefer_compound_assignment = true
dotnet_diagnostic.IDE0045.severity = suggestion
dotnet_style_prefer_conditional_expression_over_assignment = true # IDE0045
dotnet_diagnostic.IDE0046.severity = suggestion
dotnet_style_prefer_conditional_expression_over_return = true # IDE0046
dotnet_style_prefer_inferred_anonymous_type_member_names = true
dotnet_style_prefer_inferred_tuple_names = true
dotnet_style_prefer_is_null_check_over_reference_equality_method = true
dotnet_style_prefer_simplified_boolean_expressions = true
dotnet_style_prefer_simplified_interpolation = true
dotnet_style_namespace_match_folder = false # resharper: resharper_check_namespace_highlighting
# Field preferences
dotnet_style_readonly_field = true
# Suppression preferences
dotnet_remove_unnecessary_suppression_exclusions = none
# ReSharper preferences
resharper_wrap_object_and_collection_initializer_style = chop_always
resharper_check_namespace_highlighting = none
resharper_csharp_wrap_lines = false
#### C# Coding Conventions ####
[*.cs]
# var preferences
csharp_style_var_for_built_in_types = true
csharp_style_var_when_type_is_apparent = true
csharp_style_var_elsewhere = true
# Expression-bodied members
csharp_style_expression_bodied_accessors = true
csharp_style_expression_bodied_constructors = false
csharp_style_expression_bodied_indexers = true
csharp_style_expression_bodied_lambdas = true
csharp_style_expression_bodied_local_functions = false
csharp_style_expression_bodied_methods = false
csharp_style_expression_bodied_operators = false
csharp_style_expression_bodied_properties = true
# Pattern matching preferences
csharp_style_pattern_matching_over_as_with_null_check = true
csharp_style_pattern_matching_over_is_with_cast_check = true
csharp_style_prefer_not_pattern = true
csharp_style_prefer_pattern_matching = true
csharp_style_prefer_switch_expression = true
# Null-checking preferences
csharp_style_conditional_delegate_call = true
# Modifier preferences
csharp_prefer_static_local_function = true
csharp_preferred_modifier_order = public, private, protected, internal, static, extern, new, virtual, abstract, sealed, override, readonly, unsafe, volatile, async
# Code-block preferences
csharp_prefer_braces = true
csharp_prefer_simple_using_statement = true
# Expression-level preferences
csharp_prefer_simple_default_expression = true
csharp_style_deconstructed_variable_declaration = true
csharp_style_inlined_variable_declaration = true
csharp_style_pattern_local_over_anonymous_function = true
csharp_style_prefer_index_operator = true
csharp_style_prefer_range_operator = true
csharp_style_throw_expression = true
dotnet_diagnostic.IDE0058.severity = suggestion
csharp_style_unused_value_assignment_preference = discard_variable # IDE0058
csharp_style_unused_value_expression_statement_preference = discard_variable # IDE0058
# 'using' directive preferences
csharp_using_directive_placement = outside_namespace
# 'namespace' preferences
csharp_style_namespace_declarations = file_scoped
# 'constructor' preferences
csharp_style_prefer_primary_constructors = false
#### C# Formatting Rules ####
[*.cs]
# 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
#### .NET Naming styles ####
[*.{cs,vb}]
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.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
[*.{cs,vb}]
dotnet_analyzer_diagnostic.category-style.severity = warning
dotnet_analyzer_diagnostic.category-design.severity = warning
dotnet_analyzer_diagnostic.category-globalization.severity = notice
dotnet_analyzer_diagnostic.category-naming.severity = warning
dotnet_analyzer_diagnostic.category-performance.severity = warning
dotnet_analyzer_diagnostic.category-reliability.severity = warning
dotnet_analyzer_diagnostic.category-security.severity = warning
dotnet_analyzer_diagnostic.category-usage.severity = warning
dotnet_analyzer_diagnostic.category-maintainability.severity = warning
dotnet_diagnostic.CA1716.severity = none # Identifiers should not match keywords
dotnet_diagnostic.CA1816.severity = suggestion # Dispose methods should call SuppressFinalize
dotnet_diagnostic.CA1848.severity = none # Use the LoggerMessage delegates
dotnet_diagnostic.IDE0210.severity = none # Use top-level statements

View file

@ -0,0 +1,39 @@
name: default
on:
push:
branches: [ "main", "develop" ]
paths-ignore:
- "doc/**"
- "*.md"
pull_request:
branches: [ "main", "develop" ]
paths-ignore:
- "doc/**"
- "*.md"
jobs:
default:
name: dotnet-default-workflow
runs-on: debian-latest
strategy:
matrix:
dotnet-version: [ "10.0" ]
container: mcr.microsoft.com/dotnet/sdk:${{ matrix.dotnet-version }}
steps:
- name: checkout
uses: https://code.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 -p:ContinuousIntegrationBuild=true

View file

@ -0,0 +1,36 @@
name: release
on:
push:
tags: [ "[0-9]+.[0-9]+.[0-9]+" ]
jobs:
release:
name: dotnet-release-workflow
runs-on: debian-latest
strategy:
matrix:
dotnet-version: [ "10.0" ]
container: mcr.microsoft.com/dotnet/sdk:${{ matrix.dotnet-version }}
steps:
- uses: https://code.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 -p:ContinuousIntegrationBuild=true
- name: dotnet nuget push
run: |
# The token used here is only intended to publish packages
dotnet nuget push -k "${{ secrets.geekeey_package_registry }}" \
artifacts/package/release/Geekeey.*.nupkg

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
artifacts/
*.DotSettings.user

22
CHANGELOG.md Normal file
View file

@ -0,0 +1,22 @@
# Changelog
All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.0] - 2026-01-22
### Added
- This is the initial release of the library.
## [Unreleased]
### Added
### Changed
### Removed
[1.0.0]: https://code.geekeey.de/geekeey/process/releases/tag/1.0.0
[Unreleased]: https://code.geekeey.de/geekeey/process/compare/1.0.0...HEAD

37
Directory.Build.props Normal file
View file

@ -0,0 +1,37 @@
<Project>
<PropertyGroup Condition="'$(ArtifactsPath)' == ''">
<ArtifactsPath>$(MSBuildThisFileDirectory)artifacts</ArtifactsPath>
</PropertyGroup>
<PropertyGroup>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup>
<VersionPrefix>1.0.0</VersionPrefix>
</PropertyGroup>
<PropertyGroup>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<WarningsAsErrors>nullable</WarningsAsErrors>
<WarningsAsErrors Condition="'$(ContinuousIntegrationBuild)' == 'true'">true</WarningsAsErrors>
</PropertyGroup>
<PropertyGroup Label="NuGet Package Info">
<Authors>The Geekeey Team</Authors>
<Copyright>Copyright (c) The Geekeey Team 2026</Copyright>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.SourceLink.Gitea" PrivateAssets="all" />
</ItemGroup>
<PropertyGroup>
<NuGetAuditLevel>moderate</NuGetAuditLevel>
<NuGetAuditMode>all</NuGetAuditMode>
</PropertyGroup>
</Project>

2
Directory.Build.targets Normal file
View file

@ -0,0 +1,2 @@
<Project>
</Project>

11
Directory.Packages.props Normal file
View file

@ -0,0 +1,11 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Microsoft.SourceLink.Gitea" Version="10.0.102" />
<PackageVersion Include="TUnit" Version="1.11.51" />
<PackageVersion Include="Spectre.Console" Version="0.53.1" />
<PackageVersion Include="Spectre.Console.Cli" Version="0.53.1" />
</ItemGroup>
</Project>

287
LICENSE.md Normal file
View file

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

48
README.md Normal file
View file

@ -0,0 +1,48 @@
# `Geekeey.Process`
Process is a .NET 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.
## Features
- **Input and Output redirection:** flexible piping model, that allows to redirect the process's streams.
- **Immutability:** The `Command` object is immutable, ensuring thread safely and allowing sharing of a base
configuration.
## Getting Started
### Install the NuGet package:
```shell
dotnet add package Geekeey.Process
```
You may need to add our NuGet feed to your `nuget.config` this can be done by running the following command:
```shell
dotnet nuget add source -n geekeey https://code.geekeey.de/api/packages/geekeey/nuget/index.json
```
### Usage
```csharp
public static Task<int> Main()
{
var stdout = new StringBuilder();
var cmd = new Command("git").WithArguments(["config", "--get", "user.name"]) | stdout;
await cmd.ExecuteAsync();
Console.WriteLine(stdout.ToString());
return 0;
}
```
```csharp
public static Task<int> Main()
{
var stdout = new StringBuilder();
var cmd = new Command("cat").WithArguments(["file.txt"]) | new Command("wc") | stdout;
await cmd.ExecuteAsync();
Console.WriteLine(stdout.ToString());
return 0;
}
```

11
global.json Normal file
View file

@ -0,0 +1,11 @@
{
"$schema": "https://www.schemastore.org/global.json",
"sdk": {
"version": "10.0.0",
"rollForward": "latestMinor"
},
"msbuild-sdks": {},
"test": {
"runner": "Microsoft.Testing.Platform"
}
}

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://code.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>

5
process.slnx Normal file
View file

@ -0,0 +1,5 @@
<Solution>
<Project Path="src\process.dummy.app\Geekeey.Process.Dummy.App.csproj" />
<Project Path="src\process.tests\Geekeey.Process.Tests.csproj" />
<Project Path="src\process\Geekeey.Process.csproj" />
</Solution>

View file

@ -0,0 +1,13 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using Spectre.Console.Cli;
internal abstract class AsyncOutputCommand<T> : AsyncCommand<T> where T : OutputCommandSettings
{
}
internal abstract class OutputCommandSettings : CommandSettings
{
[CommandOption("--target")] public OutputTarget Target { get; init; } = OutputTarget.StdOut;
}

View file

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

View file

@ -0,0 +1,42 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
internal sealed class Output : IDisposable
{
private readonly CancellationTokenSource _cts = new();
public Output()
{
Console.CancelKeyPress += Cancel;
}
public StreamReader Stdin { get; } = new(Console.OpenStandardInput(), leaveOpen: false);
public StreamWriter Stdout { get; } = new(Console.OpenStandardOutput(), leaveOpen: false);
public StreamWriter Stderr { get; } = new(Console.OpenStandardError(), leaveOpen: false);
public CancellationToken CancellationToken => _cts.Token;
public static Output Connect()
{
return new Output();
}
private void Cancel(object? sender, ConsoleCancelEventArgs args)
{
args.Cancel = true;
_cts.Cancel();
}
public void Dispose()
{
Stdout.BaseStream.Flush();
Stdout.Dispose();
Stderr.BaseStream.Flush();
Stderr.Dispose();
Stdin.Dispose();
Console.CancelKeyPress -= Cancel;
_cts.Dispose();
}
}

View file

@ -0,0 +1,26 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
[Flags]
internal enum OutputTarget
{
StdOut = 1,
StdErr = 2,
All = StdOut | StdErr
}
internal static class OutputTargetExtensions
{
public static IEnumerable<StreamWriter> GetWriters(this Output output, OutputTarget target)
{
if (target.HasFlag(OutputTarget.StdOut))
{
yield return output.Stdout;
}
if (target.HasFlag(OutputTarget.StdErr))
{
yield return output.Stderr;
}
}
}

View file

@ -0,0 +1,45 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Reflection;
using System.Runtime.InteropServices;
using Spectre.Console.Cli;
namespace Geekeey.Process.Testing.Fixture;
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);
static void Configuration(IConfigurator configuration)
{
configuration.AddCommand<EchoCommand>("echo");
configuration.AddCommand<EchoStdinCommand>("echo-stdin");
configuration.AddCommand<EnvironmentCommand>("env");
configuration.AddCommand<WorkingDirectoryCommand>("cwd");
configuration.AddCommand<WorkingDirectoryCommand>("cwd");
configuration.AddCommand<ExitCommand>("exit");
configuration.AddCommand<LengthCommand>("length");
configuration.AddCommand<SleepCommand>("sleep");
configuration.AddBranch("generate", static generate =>
{
generate.AddCommand<GenerateBlobCommand>("blob");
generate.AddCommand<GenerateClobCommand>("clob");
});
}
}
}

View file

@ -0,0 +1,25 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using Spectre.Console.Cli;
internal sealed class EchoCommand : AsyncOutputCommand<EchoCommand.Settings>
{
public sealed class Settings : OutputCommandSettings
{
[CommandOption("--separator <char>")] public string Separator { get; init; } = " ";
[CommandArgument(0, "[line]")] public string[] Items { get; init; } = [];
}
public override async Task<int> ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken)
{
using var output = Output.Connect();
foreach (var writer in output.GetWriters(settings.Target))
{
await writer.WriteLineAsync(string.Join(settings.Separator, settings.Items));
}
return 0;
}
}

View file

@ -0,0 +1,41 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Buffers;
using Spectre.Console.Cli;
internal sealed class EchoStdinCommand : AsyncOutputCommand<EchoStdinCommand.Settings>
{
public sealed class Settings : OutputCommandSettings
{
[CommandOption("--length")] public long Length { get; init; } = long.MaxValue;
}
public override async Task<int> ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken)
{
using var output = Output.Connect();
using var buffer = MemoryPool<byte>.Shared.Rent(81920);
var count = 0L;
while (count < settings.Length)
{
var bytesWanted = (int)Math.Min(buffer.Memory.Length, settings.Length - count);
var bytesRead = await output.Stdin.BaseStream.ReadAsync(buffer.Memory[..bytesWanted], cancellationToken);
if (bytesRead <= 0)
{
break;
}
foreach (var writer in output.GetWriters(settings.Target))
{
await writer.BaseStream.WriteAsync(buffer.Memory[..bytesRead], cancellationToken);
}
count += bytesRead;
}
return 0;
}
}

View file

@ -0,0 +1,29 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using Spectre.Console.Cli;
internal sealed class EnvironmentCommand : AsyncOutputCommand<EnvironmentCommand.Settings>
{
public sealed class Settings : OutputCommandSettings
{
[CommandArgument(0, "<ARGUMENT>")] public string[] Variables { get; init; } = [];
}
public override async Task<int> ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken)
{
using var output = Output.Connect();
foreach (var name in settings.Variables)
{
var value = Environment.GetEnvironmentVariable(name) ?? string.Empty;
foreach (var writer in output.GetWriters(settings.Target))
{
await writer.WriteLineAsync(value);
}
}
return 0;
}
}

View file

@ -0,0 +1,21 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using Spectre.Console.Cli;
internal sealed class ExitCommand : AsyncCommand<ExitCommand.Settings>
{
public sealed class Settings : CommandSettings
{
[CommandArgument(1, "<code>")] public int Code { get; init; }
}
public override async Task<int> ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken)
{
using var output = Output.Connect();
await output.Stderr.WriteLineAsync($"Exit code set to {settings.Code}");
return settings.Code;
}
}

View file

@ -0,0 +1,40 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Buffers;
using Spectre.Console.Cli;
internal sealed class GenerateBlobCommand : AsyncOutputCommand<GenerateBlobCommand.Settings>
{
private readonly Random _random = new(1234567);
public sealed class Settings : OutputCommandSettings
{
[CommandOption("--length")] public long Length { get; init; } = 100_000;
[CommandOption("--buffer")] public int BufferSize { get; init; } = 1024;
}
public override async Task<int> ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken)
{
using var output = Output.Connect();
using var bytes = MemoryPool<byte>.Shared.Rent(settings.BufferSize);
var total = 0L;
while (total < settings.Length)
{
_random.NextBytes(bytes.Memory.Span);
var count = (int)Math.Min(bytes.Memory.Length, settings.Length - total);
foreach (var writer in output.GetWriters(settings.Target))
{
await writer.BaseStream.WriteAsync(bytes.Memory[..count], cancellationToken);
}
total += count;
}
return 0;
}
}

View file

@ -0,0 +1,42 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Text;
using Spectre.Console.Cli;
internal sealed class GenerateClobCommand : AsyncOutputCommand<GenerateClobCommand.Settings>
{
private readonly Random _random = new(1234567);
private readonly char[] _chars = [.. Enumerable.Range(32, 94).Select(i => (char)i)];
public sealed class Settings : OutputCommandSettings
{
[CommandOption("--length")] public int Length { get; init; } = 100_000;
[CommandOption("--lines")] public int LinesCount { get; init; } = 1;
}
public override async Task<int> ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken)
{
using var output = Output.Connect();
var buffer = new StringBuilder(settings.Length);
for (var line = 0; line < settings.LinesCount; line++)
{
buffer.Clear();
for (var i = 0; i < settings.Length; i++)
{
buffer.Append(_chars[_random.Next(0, _chars.Length)]);
}
foreach (var writer in output.GetWriters(settings.Target))
{
await writer.WriteLineAsync(buffer.ToString());
}
}
return 0;
}
}

View file

@ -0,0 +1,40 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Buffers;
using System.Globalization;
using Spectre.Console.Cli;
internal sealed class LengthCommand : AsyncOutputCommand<LengthCommand.Settings>
{
public sealed class Settings : OutputCommandSettings
{
}
public override async Task<int> ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken)
{
using var output = Output.Connect();
using var buffer = MemoryPool<byte>.Shared.Rent(81920);
var count = 0L;
while (true)
{
var bytesRead = await output.Stdin.BaseStream.ReadAsync(buffer.Memory, cancellationToken);
if (bytesRead <= 0)
{
break;
}
count += bytesRead;
}
foreach (var writer in output.GetWriters(settings.Target))
{
await writer.WriteLineAsync(count.ToString(CultureInfo.InvariantCulture));
}
return 0;
}
}

View file

@ -0,0 +1,34 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using Spectre.Console.Cli;
internal sealed class SleepCommand : AsyncCommand<SleepCommand.Settings>
{
public sealed class Settings : CommandSettings
{
[CommandArgument(0, "[duration]")] public TimeSpan Duration { get; init; } = TimeSpan.FromSeconds(1);
}
public override async Task<int> ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken)
{
using var output = Output.Connect();
try
{
await Console.Out.WriteLineAsync($"Sleeping for {settings.Duration}...");
await Console.Out.FlushAsync(CancellationToken.None);
await Task.Delay(settings.Duration, output.CancellationToken);
}
catch (OperationCanceledException)
{
await Console.Out.WriteLineAsync("Canceled.");
await Console.Out.FlushAsync(CancellationToken.None);
}
await Console.Out.WriteLineAsync("Done.");
await Console.Out.FlushAsync(CancellationToken.None);
return 0;
}
}

View file

@ -0,0 +1,23 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using Spectre.Console.Cli;
internal sealed class WorkingDirectoryCommand : AsyncOutputCommand<WorkingDirectoryCommand.Settings>
{
public sealed class Settings : OutputCommandSettings
{
}
public override async Task<int> ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken)
{
using var output = Output.Connect();
foreach (var writer in output.GetWriters(settings.Target))
{
await writer.WriteLineAsync(Directory.GetCurrentDirectory());
}
return 0;
}
}

View file

@ -0,0 +1,181 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Text;
using Geekeey.Process.Buffered;
namespace Geekeey.Process.Tests;
internal sealed class CancellationTests
{
private static Action<string> NotifyOnStart(out TaskCompletionSource tcs)
{
// run the continuation async on the thread pool to allow the io reader to complete
var source = tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
return line =>
{
if (line.Contains("Sleeping for", StringComparison.OrdinalIgnoreCase))
{
source.TrySetResult();
}
};
}
[Test]
public async Task I_can_execute_a_command_and_cancel_it_immediately()
{
// Arrange
using var cts = new CancellationTokenSource();
var stdout = new StringBuilder();
var target = PipeTarget.Merge(
PipeTarget.ToDelegate(NotifyOnStart(out var tcs)),
PipeTarget.ToStringBuilder(stdout)
);
var cmd = new Command(Testing.Fixture.Program.FilePath)
.WithArguments(["sleep", "00:00:30"]) |
target;
// Act
var task = cmd.ExecuteAsync(cts.Token);
await tcs.Task;
await cts.CancelAsync();
// Assert
await Assert.That(async () => await task).Throws<OperationCanceledException>();
using (Assert.Multiple())
{
await Assert.That(ProcessTree.HasExited(task.ProcessId)).IsTrue();
await Assert.That(stdout.ToString()).Contains("Sleeping for");
await Assert.That(stdout.ToString()).DoesNotContain("Done.");
}
}
[Test]
public async Task I_can_execute_a_command_and_kill_it_immediately()
{
// Arrange
var stdout = new StringBuilder();
var target = PipeTarget.Merge(
PipeTarget.ToDelegate(NotifyOnStart(out var tcs)),
PipeTarget.ToStringBuilder(stdout)
);
var cmd = new Command(Testing.Fixture.Program.FilePath)
.WithArguments(["sleep", "00:00:30"]) |
target;
// Act
var task = cmd.ExecuteAsync();
await tcs.Task;
task.Kill();
// Assert
await Assert.That(async () => await task).Throws<CommandExecutionException>();
using (Assert.Multiple())
{
await Assert.That(ProcessTree.HasExited(task.ProcessId)).IsTrue();
await Assert.That(stdout.ToString()).Contains("Sleeping for");
await Assert.That(stdout.ToString()).DoesNotContain("Done.");
}
}
[Test]
public async Task I_can_execute_a_command_with_buffering_and_kill_it_immediately()
{
// Arrange
var stdout = new StringBuilder();
var target = PipeTarget.Merge(
PipeTarget.ToDelegate(NotifyOnStart(out var tcs)),
PipeTarget.ToStringBuilder(stdout)
);
var cmd = new Command(Testing.Fixture.Program.FilePath)
.WithArguments(["sleep", "00:00:30"]) |
target;
// Act
var task = cmd.ExecuteBufferedAsync();
await tcs.Task;
task.Kill();
// Assert
await Assert.That(async () => await task).Throws<CommandExecutionException>();
using (Assert.Multiple())
{
await Assert.That(ProcessTree.HasExited(task.ProcessId)).IsTrue();
await Assert.That(stdout.ToString()).Contains("Sleeping for");
await Assert.That(stdout.ToString()).DoesNotContain("Done.");
}
}
[Test]
public async Task I_can_execute_a_command_and_interrupt_it_immediately()
{
// Arrange
var stdout = new StringBuilder();
var target = PipeTarget.Merge(
PipeTarget.ToDelegate(NotifyOnStart(out var tcs)),
PipeTarget.ToStringBuilder(stdout)
);
var cmd = new Command(Testing.Fixture.Program.FilePath)
.WithArguments(["sleep", "00:00:30"]) |
target;
// Act
var task = cmd.ExecuteAsync();
await tcs.Task;
task.Interrupt();
// Assert
await Assert.That(async () => await task).ThrowsNothing();
using (Assert.Multiple())
{
await Assert.That(ProcessTree.HasExited(task.ProcessId)).IsTrue();
await Assert.That(stdout.ToString()).Contains("Sleeping for");
await Assert.That(stdout.ToString()).Contains("Done.");
}
}
[Test]
public async Task I_can_execute_a_command_with_buffering_and_interrupt_it_immediately()
{
// Arrange
var stdout = new StringBuilder();
var target = PipeTarget.Merge(
PipeTarget.ToDelegate(NotifyOnStart(out var tcs)),
PipeTarget.ToStringBuilder(stdout)
);
var cmd = new Command(Testing.Fixture.Program.FilePath)
.WithArguments(["sleep", "00:00:30"]) |
target;
// Act
var task = cmd.ExecuteBufferedAsync();
await tcs.Task;
task.Interrupt();
// Assert
await Assert.That(async () => await task).ThrowsNothing();
using (Assert.Multiple())
{
await Assert.That(ProcessTree.HasExited(task.ProcessId)).IsTrue();
await Assert.That(stdout.ToString()).Contains("Sleeping for");
await Assert.That(stdout.ToString()).Contains("Done.");
}
}
}

View file

@ -0,0 +1,273 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Process.Tests;
internal sealed class CommandTests
{
[Test]
public async Task I_can_create_a_command_with_the_default_configuration()
{
var cmd = new Command("foo");
using (Assert.Multiple())
{
await Assert.That(cmd.TargetFilePath).IsEqualTo("foo");
await Assert.That(cmd.Arguments).IsEmpty();
await Assert.That(cmd.WorkingDirPath).IsEqualTo(Directory.GetCurrentDirectory());
await Assert.That(cmd.Environment).IsEmpty();
await Assert.That(cmd.Validation).HasFlag(ValidationMode.ZeroExitCode);
await Assert.That(cmd.StandardInputPipe).IsEqualTo(PipeSource.Null);
await Assert.That(cmd.StandardOutputPipe).IsEqualTo(PipeTarget.Null);
await Assert.That(cmd.StandardErrorPipe).IsEqualTo(PipeTarget.Null);
}
}
[Test]
public async Task I_can_configure_the_target_file()
{
var cmd = new Command("foo");
var modified = cmd.WithTargetFile("bar");
using (Assert.Multiple())
{
await Assert.That(modified.TargetFilePath).IsEqualTo("bar");
await Assert.That(modified.Arguments).IsEqualTo(cmd.Arguments);
await Assert.That(modified.WorkingDirPath).IsEqualTo(cmd.WorkingDirPath);
await Assert.That(modified.Environment).IsEqualTo(cmd.Environment);
await Assert.That(modified.Validation).IsEqualTo(cmd.Validation);
await Assert.That(modified.StandardInputPipe).IsEqualTo(cmd.StandardInputPipe);
await Assert.That(modified.StandardOutputPipe).IsEqualTo(cmd.StandardOutputPipe);
await Assert.That(modified.StandardErrorPipe).IsEqualTo(cmd.StandardErrorPipe);
await Assert.That(cmd.TargetFilePath).IsNotEqualTo("bar");
}
}
[Test]
public async Task I_can_configure_the_command_line_arguments()
{
var cmd = new Command("foo").WithArguments("xxx");
var modified = cmd.WithArguments("abc def");
using (Assert.Multiple())
{
await Assert.That(modified.TargetFilePath).IsEqualTo(cmd.TargetFilePath);
await Assert.That(modified.Arguments).IsEqualTo("abc def");
await Assert.That(modified.WorkingDirPath).IsEqualTo(cmd.WorkingDirPath);
await Assert.That(modified.Environment).IsEqualTo(cmd.Environment);
await Assert.That(modified.Validation).IsEqualTo(cmd.Validation);
await Assert.That(modified.StandardInputPipe).IsEqualTo(cmd.StandardInputPipe);
await Assert.That(modified.StandardOutputPipe).IsEqualTo(cmd.StandardOutputPipe);
await Assert.That(modified.StandardErrorPipe).IsEqualTo(cmd.StandardErrorPipe);
await Assert.That(cmd.Arguments).IsNotEqualTo("abc def");
}
}
[Test]
public async Task I_can_configure_the_command_line_arguments_by_passing_an_array()
{
var cmd = new Command("foo").WithArguments("xxx");
var modified = cmd.WithArguments(["abc", "def"]);
using (Assert.Multiple())
{
await Assert.That(modified.TargetFilePath).IsEqualTo(cmd.TargetFilePath);
await Assert.That(modified.Arguments).IsEqualTo("abc def");
await Assert.That(modified.WorkingDirPath).IsEqualTo(cmd.WorkingDirPath);
await Assert.That(modified.Environment).IsEqualTo(cmd.Environment);
await Assert.That(modified.Validation).IsEqualTo(cmd.Validation);
await Assert.That(modified.StandardInputPipe).IsEqualTo(cmd.StandardInputPipe);
await Assert.That(modified.StandardOutputPipe).IsEqualTo(cmd.StandardOutputPipe);
await Assert.That(modified.StandardErrorPipe).IsEqualTo(cmd.StandardErrorPipe);
await Assert.That(cmd.Arguments).IsNotEqualTo("abc def");
}
}
[Test]
public async Task I_can_configure_the_command_line_arguments_using_a_builder()
{
var cmd = new Command("foo").WithArguments("xxx");
var modified = cmd.WithArguments(args => args
.Add("-a")
.Add("foo bar")
.Add("\"foo\\\\bar\"")
.Add(3.14)
.Add(["foo", "bar"])
.Add([-10, 12.12]));
using (Assert.Multiple())
{
await Assert.That(modified.TargetFilePath).IsEqualTo(cmd.TargetFilePath);
await Assert.That(modified.Arguments).IsEqualTo("-a \"foo bar\" \"\\\"foo\\\\bar\\\"\" 3.14 foo bar -10 12.12");
await Assert.That(modified.WorkingDirPath).IsEqualTo(cmd.WorkingDirPath);
await Assert.That(modified.Environment).IsEqualTo(cmd.Environment);
await Assert.That(modified.Validation).IsEqualTo(cmd.Validation);
await Assert.That(modified.StandardInputPipe).IsEqualTo(cmd.StandardInputPipe);
await Assert.That(modified.StandardOutputPipe).IsEqualTo(cmd.StandardOutputPipe);
await Assert.That(modified.StandardErrorPipe).IsEqualTo(cmd.StandardErrorPipe);
await Assert.That(cmd.Arguments).IsNotEqualTo("-a \"foo bar\" \"\\\"foo\\\\bar\\\"\" 3.14 foo bar -10 12.12");
}
}
[Test]
public async Task I_can_configure_the_working_directory()
{
var cmd = new Command("foo").WithWorkingDirectory("xxx");
var modified = cmd.WithWorkingDirectory("new");
using (Assert.Multiple())
{
await Assert.That(modified.TargetFilePath).IsEqualTo(cmd.TargetFilePath);
await Assert.That(modified.Arguments).IsEqualTo(cmd.Arguments);
await Assert.That(modified.WorkingDirPath).IsEqualTo("new");
await Assert.That(modified.Environment).IsEqualTo(cmd.Environment);
await Assert.That(modified.Validation).IsEqualTo(cmd.Validation);
await Assert.That(modified.StandardInputPipe).IsEqualTo(cmd.StandardInputPipe);
await Assert.That(modified.StandardOutputPipe).IsEqualTo(cmd.StandardOutputPipe);
await Assert.That(modified.StandardErrorPipe).IsEqualTo(cmd.StandardErrorPipe);
await Assert.That(cmd.WorkingDirPath).IsNotEqualTo("new");
}
}
[Test]
public async Task I_can_configure_the_environment_variables()
{
var cmd = new Command("foo").WithEnvironment(e => e.Set("xxx", "xxx"));
var vars = new Dictionary<string, string?>
{
["name"] = "value",
["key"] = "door",
};
var modified = cmd.WithEnvironment(vars);
using (Assert.Multiple())
{
await Assert.That(modified.TargetFilePath).IsEqualTo(cmd.TargetFilePath);
await Assert.That(modified.Arguments).IsEqualTo(cmd.Arguments);
await Assert.That(modified.WorkingDirPath).IsEqualTo(cmd.WorkingDirPath);
await Assert.That(modified.Environment).IsEqualTo(vars);
await Assert.That(modified.Validation).IsEqualTo(cmd.Validation);
await Assert.That(modified.StandardInputPipe).IsEqualTo(cmd.StandardInputPipe);
await Assert.That(modified.StandardOutputPipe).IsEqualTo(cmd.StandardOutputPipe);
await Assert.That(modified.StandardErrorPipe).IsEqualTo(cmd.StandardErrorPipe);
await Assert.That(cmd.Environment).IsNotEqualTo(vars);
}
}
[Test]
public async Task I_can_configure_the_environment_variables_using_a_builder()
{
var cmd = new Command("foo").WithEnvironment(e => e.Set("xxx", "xxx"));
var modified = cmd.WithEnvironment(env => env
.Set("name", "value")
.Set("key", "door")
.Set(new Dictionary<string, string?>
{
["zzz"] = "yyy",
["aaa"] = "bbb",
}));
using (Assert.Multiple())
{
var vars = new Dictionary<string, string?>
{
["name"] = "value",
["key"] = "door",
["zzz"] = "yyy",
["aaa"] = "bbb",
};
await Assert.That(modified.TargetFilePath).IsEqualTo(cmd.TargetFilePath);
await Assert.That(modified.Arguments).IsEqualTo(cmd.Arguments);
await Assert.That(modified.WorkingDirPath).IsEqualTo(cmd.WorkingDirPath);
await Assert.That(modified.Environment).IsEquivalentTo(vars);
await Assert.That(modified.Validation).IsEqualTo(cmd.Validation);
await Assert.That(modified.StandardInputPipe).IsEqualTo(cmd.StandardInputPipe);
await Assert.That(modified.StandardOutputPipe).IsEqualTo(cmd.StandardOutputPipe);
await Assert.That(modified.StandardErrorPipe).IsEqualTo(cmd.StandardErrorPipe);
await Assert.That(cmd.Environment).IsNotEqualTo(vars);
}
}
[Test]
public async Task I_can_configure_the_result_validation_strategy()
{
var cmd = new Command("foo").WithExitValidation(ValidationMode.ZeroExitCode);
var modified = cmd.WithExitValidation(ValidationMode.None);
using (Assert.Multiple())
{
await Assert.That(modified.TargetFilePath).IsEqualTo(cmd.TargetFilePath);
await Assert.That(modified.Arguments).IsEqualTo(cmd.Arguments);
await Assert.That(modified.WorkingDirPath).IsEqualTo(cmd.WorkingDirPath);
await Assert.That(modified.Environment).IsEqualTo(cmd.Environment);
await Assert.That(modified.Validation).IsEqualTo(ValidationMode.None);
await Assert.That(modified.StandardInputPipe).IsEqualTo(cmd.StandardInputPipe);
await Assert.That(modified.StandardOutputPipe).IsEqualTo(cmd.StandardOutputPipe);
await Assert.That(modified.StandardErrorPipe).IsEqualTo(cmd.StandardErrorPipe);
await Assert.That(cmd.Validation).IsNotEqualTo(ValidationMode.None);
}
}
[Test]
public async Task I_can_configure_the_stdin_pipe()
{
var cmd = new Command("foo").WithStandardInputPipe(PipeSource.Null);
var pipeSource = PipeSource.FromStream(Stream.Null);
var modified = cmd.WithStandardInputPipe(pipeSource);
using (Assert.Multiple())
{
await Assert.That(modified.TargetFilePath).IsEqualTo(cmd.TargetFilePath);
await Assert.That(modified.Arguments).IsEqualTo(cmd.Arguments);
await Assert.That(modified.WorkingDirPath).IsEqualTo(cmd.WorkingDirPath);
await Assert.That(modified.Environment).IsEqualTo(cmd.Environment);
await Assert.That(modified.Validation).IsEqualTo(cmd.Validation);
await Assert.That(modified.StandardInputPipe).IsEqualTo(pipeSource);
await Assert.That(modified.StandardOutputPipe).IsEqualTo(cmd.StandardOutputPipe);
await Assert.That(modified.StandardErrorPipe).IsEqualTo(cmd.StandardErrorPipe);
await Assert.That(cmd.StandardInputPipe).IsNotEqualTo(pipeSource);
}
}
[Test]
public async Task I_can_configure_the_stdout_pipe()
{
var cmd = new Command("foo").WithStandardOutputPipe(PipeTarget.Null);
var pipeTarget = PipeTarget.ToStream(Stream.Null);
var modified = cmd.WithStandardOutputPipe(pipeTarget);
using (Assert.Multiple())
{
await Assert.That(modified.TargetFilePath).IsEqualTo(cmd.TargetFilePath);
await Assert.That(modified.Arguments).IsEqualTo(cmd.Arguments);
await Assert.That(modified.WorkingDirPath).IsEqualTo(cmd.WorkingDirPath);
await Assert.That(modified.Environment).IsEqualTo(cmd.Environment);
await Assert.That(modified.Validation).IsEqualTo(cmd.Validation);
await Assert.That(modified.StandardInputPipe).IsEqualTo(cmd.StandardInputPipe);
await Assert.That(modified.StandardOutputPipe).IsEqualTo(pipeTarget);
await Assert.That(modified.StandardErrorPipe).IsEqualTo(cmd.StandardErrorPipe);
await Assert.That(cmd.StandardOutputPipe).IsNotEqualTo(pipeTarget);
}
}
[Test]
public async Task I_can_configure_the_stderr_pipe()
{
var cmd = new Command("foo").WithStandardErrorPipe(PipeTarget.Null);
var pipeTarget = PipeTarget.ToStream(Stream.Null);
var modified = cmd.WithStandardErrorPipe(pipeTarget);
using (Assert.Multiple())
{
await Assert.That(modified.TargetFilePath).IsEqualTo(cmd.TargetFilePath);
await Assert.That(modified.Arguments).IsEqualTo(cmd.Arguments);
await Assert.That(modified.WorkingDirPath).IsEqualTo(cmd.WorkingDirPath);
await Assert.That(modified.Environment).IsEqualTo(cmd.Environment);
await Assert.That(modified.Validation).IsEqualTo(cmd.Validation);
await Assert.That(modified.StandardInputPipe).IsEqualTo(cmd.StandardInputPipe);
await Assert.That(modified.StandardOutputPipe).IsEqualTo(cmd.StandardOutputPipe);
await Assert.That(modified.StandardErrorPipe).IsEqualTo(pipeTarget);
await Assert.That(cmd.StandardErrorPipe).IsNotEqualTo(pipeTarget);
}
}
}

View file

@ -0,0 +1,139 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using Geekeey.Process.Buffered;
namespace Geekeey.Process.Tests;
internal sealed class ExecuteTests
{
[Test]
public async Task I_can_execute_a_command_and_get_the_exit_code_and_execution_time()
{
// Arrange
var cmd = new Command(Testing.Fixture.Program.FilePath)
.WithArguments(["echo"]);
// Act
var result = await cmd.ExecuteAsync();
await Assert.That(result.ExitCode).IsZero();
// Assert
using (Assert.Multiple())
{
await Assert.That(result.ExitCode).IsZero();
await Assert.That(result.IsSuccess).IsTrue();
await Assert.That(result.RunTime).IsGreaterThan(TimeSpan.Zero);
}
}
[Test]
public async Task I_can_execute_a_command_and_get_the_associated_process_id()
{
// Arrange
var cmd = new Command(Testing.Fixture.Program.FilePath)
.WithArguments(["echo"]);
// Act
var task = cmd.ExecuteAsync();
// Assert
await Assert.That(task.ProcessId).IsNotZero();
await task;
}
[Test]
public async Task I_can_execute_a_command_with_a_configured_awaiter()
{
// Arrange
var cmd = new Command(Testing.Fixture.Program.FilePath)
.WithArguments(["echo"]);
// Act + Assert
await cmd.ExecuteAsync().ConfigureAwait(false);
}
[Test]
public async Task I_can_try_to_execute_a_command_and_get_an_error_if_the_target_file_does_not_exist()
{
// Arrange
var cmd = new Command("some_exe_with_does_not_exits");
// Act + Assert
await Assert.That(() => cmd.ExecuteAsync()).Throws<InvalidOperationException>()
.WithInnerException();
}
[Test]
public async Task I_can_execute_a_command_with_a_custom_working_directory()
{
// Arrange
using var dir = TestTempDirectory.Create();
var cmd = new Command(Testing.Fixture.Program.FilePath)
.WithArguments("cwd")
.WithWorkingDirectory(dir.Path);
// Act
var result = await cmd.ExecuteBufferedAsync();
await Assert.That(result.ExitCode).IsZero();
// Assert
var lines = result.StandardOutput.Split(Environment.NewLine);
await Assert.That(lines).Contains(dir.Path);
}
[Test]
public async Task I_can_execute_a_command_with_additional_environment_variables()
{
// Arrange
var cmd = new Command(Testing.Fixture.Program.FilePath)
.WithArguments(["env", "foo", "bar"])
.WithEnvironment(env => env
.Set("foo", "hello")
.Set("bar", "world"));
// Act
var result = await cmd.ExecuteBufferedAsync();
await Assert.That(result.ExitCode).IsZero();
// Assert
var lines = result.StandardOutput.Split(Environment.NewLine);
await Assert.That(lines).Contains("hello");
await Assert.That(lines).Contains("world");
}
[Test]
public async Task I_can_execute_a_command_with_some_environment_variables_overwritten()
{
// Arrange
var key = Guid.NewGuid();
var variableToKeep = $"GKY_TEST_KEEP_{key}";
var variableToOverwrite = $"GKY_TEST_OVERWRITE_{key}";
var variableToUnset = $"GKY_TEST_UNSET_{key}";
using var a = TestEnvironment.Create(variableToKeep, "keep");
using var b = TestEnvironment.Create(variableToOverwrite, "overwrite");
using var c = TestEnvironment.Create(variableToUnset, "unset");
var cmd = new Command(Testing.Fixture.Program.FilePath)
.WithArguments(["env", variableToKeep, variableToOverwrite, variableToUnset])
.WithEnvironment(env => env
.Set(variableToOverwrite, "overwritten")
.Set(variableToUnset, null));
// Act
var result = await cmd.ExecuteBufferedAsync();
await Assert.That(result.ExitCode).IsZero();
// Assert
var lines = result.StandardOutput.Split(Environment.NewLine);
await Assert.That(lines).Contains("keep");
await Assert.That(lines).Contains("overwritten");
}
}

View file

@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<PropertyGroup>
<RootNamespace>Geekeey.Process</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="TUnit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\process\Geekeey.Process.csproj" />
<ProjectReference Include="..\process.dummy.app\Geekeey.Process.Dummy.App.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,81 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Process.Tests;
internal sealed class LineBreakTests
{
private static Command Echo()
{
return new Command(Testing.Fixture.Program.FilePath)
.WithArguments("echo-stdin");
}
[Test]
public async Task I_can_execute_a_command_and_split_the_stdout_by_newline()
{
// Arrange
const string data = "Foo\nBar\nBaz";
var stdOutLines = new List<string>();
var cmd = data | Echo() | stdOutLines.Add;
// Act
await cmd.ExecuteAsync();
// Assert
await Assert.That(stdOutLines).IsEquivalentTo(["Foo", "Bar", "Baz"]);
}
[Test]
public async Task I_can_execute_a_command_and_split_the_stdout_by_caret_return()
{
// Arrange
const string data = "Foo\rBar\rBaz";
var stdOutLines = new List<string>();
var cmd = data | Echo() | stdOutLines.Add;
// Act
await cmd.ExecuteAsync();
// Assert
await Assert.That(stdOutLines).IsEquivalentTo(["Foo", "Bar", "Baz"]);
}
[Test]
public async Task I_can_execute_a_command_and_split_the_stdout_by_caret_return_followed_by_newline()
{
// Arrange
const string data = "Foo\r\nBar\r\nBaz";
var stdOutLines = new List<string>();
var cmd = data | Echo() | stdOutLines.Add;
// Act
await cmd.ExecuteAsync();
// Assert
await Assert.That(stdOutLines).IsEquivalentTo(["Foo", "Bar", "Baz"]);
}
[Test]
public async Task I_can_execute_a_command_and_split_the_stdout_by_newline_while_including_empty_lines()
{
// Arrange
const string data = "Foo\r\rBar\n\nBaz";
var stdOutLines = new List<string>();
var cmd = data | Echo() | stdOutLines.Add;
// Act
await cmd.ExecuteAsync();
// Assert
await Assert.That(stdOutLines).IsEquivalentTo(["Foo", "", "Bar", "", "Baz"]);
}
}

View file

@ -0,0 +1,50 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using Geekeey.Process.Buffered;
namespace Geekeey.Process.Tests;
internal sealed class PathResolutionTests
{
[Test]
public async Task I_can_execute_a_command_on_an_executable_using_its_short_name()
{
// Arrange
var cmd = new Command("dotnet")
.WithArguments("--version");
// Act
var result = await cmd.ExecuteBufferedAsync();
// Assert
using (Assert.Multiple())
{
await Assert.That(result.ExitCode).IsEqualTo(0);
await Assert.That(result.StandardOutput.Trim()).Matches(@"^\d+\.\d+\.\d+$");
}
}
[Test]
[Platform(PlatformAttribute.Windows)]
public async Task I_can_execute_a_command_on_a_script_using_its_short_name()
{
// Arrange
using var dir = TestTempDirectory.Create();
await File.WriteAllTextAsync(Path.Combine(dir.Path, "script.cmd"), "@echo hi");
using var _1 = TestEnvironment.ExtendPath(dir.Path);
var cmd = new Command("script");
// Act
var result = await cmd.ExecuteBufferedAsync();
// Assert
// Assert
using (Assert.Multiple())
{
await Assert.That(result.ExitCode).IsEqualTo(0);
await Assert.That(result.StandardOutput.Trim()).IsEqualTo("hi");
}
}
}

View file

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

View file

@ -0,0 +1,58 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using Geekeey.Process.Buffered;
namespace Geekeey.Process.Tests;
internal sealed class ValidationTests
{
private static Command Exit()
{
return new Command(Testing.Fixture.Program.FilePath)
.WithArguments(["exit", "1"]);
}
[Test]
public async Task I_can_try_to_execute_a_command_and_get_an_error_if_it_returns_a_non_zero_exit_code()
{
// Arrange
var cmd = Exit();
// Act & Assert
await Assert.That(async () => await cmd.ExecuteAsync()).Throws<CommandExecutionException>().And
.Member(static exception => exception.Message, static source => source.Contains("a non-zero exit code (1)")).And
.Member(static exception => exception.ExitCode, static source => source.IsEqualTo(1));
}
[Test]
public async Task I_can_try_to_execute_a_command_with_buffering_and_get_a_detailed_error_if_it_returns_a_non_zero_exit_code()
{
// Arrange
var cmd = Exit();
// Act & Assert
await Assert.That(async () => await cmd.ExecuteBufferedAsync()).Throws<CommandExecutionException>().And
.Member(static exception => exception.Message, static source => source.Contains("Exit code set to 1")).And
.Member(static exception => exception.ExitCode, static source => source.IsEqualTo(1));
}
[Test]
public async Task I_can_execute_a_command_without_validating_the_exit_code()
{
// Arrange
var cmd = Exit()
.WithExitValidation(ValidationMode.None);
// Act
var result = await cmd.ExecuteAsync();
// Assert
using (Assert.Multiple())
{
await Assert.That(result.ExitCode).IsEqualTo(1);
await Assert.That(result.IsSuccess).IsFalse();
}
}
}

View file

@ -0,0 +1,35 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Process.Tests;
internal sealed class PlatformAttribute : SkipAttribute
{
// from the OperatingSystem definitions
public const string Browser = "BROWSER";
public const string Wasi = "WASI";
public const string Windows = "WINDOWS";
public const string Osx = "OSX";
public const string MacCatalyst = "MACCATALYST";
public const string Ios = "IOS";
public const string Tvos = "TVOS";
public const string Android = "ANDROID";
public const string Linux = "LINUX";
public const string Freebsd = "FREEBSD";
public const string Netbsd = "NETBSD";
public const string Illumos = "ILLUMOS";
public const string Solaris = "SOLARIS";
private readonly string[] _os;
public PlatformAttribute(params string[] os) : base("Test skipped on unsupported platform.")
{
_os = os;
}
public override Task<bool> ShouldSkip(TestRegisteredContext context)
{
return Task.FromResult(!_os.Any(OperatingSystem.IsOSPlatform));
}
}

View file

@ -0,0 +1,21 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Process.Tests;
internal static class ProcessTree
{
public static bool HasExited(int id)
{
try
{
using var process = System.Diagnostics.Process.GetProcessById(id);
return process.HasExited;
}
catch
{
// GetProcessById throws if the process can not be found, which means it is not running!
return true;
}
}
}

View file

@ -0,0 +1,32 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Process.Tests;
internal sealed class TestEnvironment : IDisposable
{
private readonly Action _action;
private TestEnvironment(Action action)
{
_action = action;
}
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,34 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Process.Tests;
internal sealed class TestTempDirectory : IDisposable
{
private TestTempDirectory(string path)
{
Path = path;
}
public static TestTempDirectory Create()
{
var location = System.Reflection.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; }
public void Dispose()
{
try
{
Directory.Delete(Path, recursive: true);
}
catch (DirectoryNotFoundException) { }
}
}

View file

@ -0,0 +1,153 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Globalization;
using System.Text;
namespace Geekeey.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)
{
return 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)
{
return 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)
{
return Add(values, DefaultFormatProvider, escape);
}
/// <summary>
/// Builds the resulting arguments string.
/// </summary>
public string Build()
{
return _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,91 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Text;
namespace Geekeey.Process.Buffered;
/// <summary>
/// Buffered execution model.
/// </summary>
public static class BufferedCommandExtensions
{
/// <inheritdoc cref="BufferedCommandExtensions"/>
extension(Command command)
{
/// <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 CommandTask<BufferedCommandResult> ExecuteBufferedAsync(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 CommandTask<BufferedCommandResult> ExecuteBufferedAsync(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 CommandTask<BufferedCommandResult> ExecuteBufferedAsync(CancellationToken cancellationToken = default)
{
return command.ExecuteBufferedAsync(Console.OutputEncoding, cancellationToken);
}
}
}

View file

@ -0,0 +1,51 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Process.Buffered;
/// <summary>
/// Result of a command execution, with buffered text data from standard output and standard error streams.
/// </summary>
public partial class BufferedCommandResult : CommandResult
{
/// <summary>
/// Result of a command execution, with buffered text data from standard output and standard error streams.
/// </summary>
public BufferedCommandResult(int exitCode, DateTimeOffset startTime, DateTimeOffset exitTime, string standardOutput, string standardError)
: base(exitCode, startTime, exitTime)
{
StandardOutput = standardOutput;
StandardError = standardError;
}
/// <summary>
/// Standard output data produced by the underlying process.
/// </summary>
public string StandardOutput { get; }
/// <summary>
/// Standard error data produced by the underlying process.
/// </summary>
public string StandardError { get; }
/// <summary>
/// Deconstructs the result into its most important components.
/// </summary>
public void Deconstruct(out int exitCode, out string standardOutput, out string standardError)
{
exitCode = ExitCode;
standardOutput = StandardOutput;
standardError = StandardError;
}
}
public partial class BufferedCommandResult
{
/// <summary>
/// Converts the result to a string value that corresponds to the <see cref="BufferedCommandResult.StandardOutput" /> property.
/// </summary>
public static implicit operator string(BufferedCommandResult result)
{
return result.StandardOutput;
}
}

View file

@ -0,0 +1,129 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Diagnostics.Contracts;
namespace Geekeey.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)
{
return new Command(targetFilePath, Arguments, WorkingDirPath, Environment, 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)
{
return new Command(TargetFilePath, arguments, WorkingDirPath, Environment, 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)
{
return 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)
{
return new Command(TargetFilePath, Arguments, workingDirPath, Environment, Validation,
StandardInputPipe, StandardOutputPipe, StandardErrorPipe);
}
/// <summary>
/// Creates a copy of this command, setting the environment variables to the specified value.
/// </summary>
[Pure]
public Command WithEnvironment(IReadOnlyDictionary<string, string?> environmentVariables)
{
return new Command(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 WithEnvironment(Action<EnvironmentVariablesBuilder> configure)
{
var builder = new EnvironmentVariablesBuilder();
configure(builder);
return WithEnvironment(builder.Build());
}
/// <summary>
/// Creates a copy of this command, setting the ExitMode options to the specified value.
/// </summary>
[Pure]
public Command WithExitValidation(ValidationMode validationMode)
{
return new Command(TargetFilePath, Arguments, WorkingDirPath, Environment, validationMode,
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)
{
return new Command(TargetFilePath, Arguments, WorkingDirPath, Environment, 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)
{
return new Command(TargetFilePath, Arguments, WorkingDirPath, Environment, 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)
{
return new Command(TargetFilePath, Arguments, WorkingDirPath, Environment, Validation,
StandardInputPipe, StandardOutputPipe, target);
}
}

View file

@ -0,0 +1,215 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Process;
public sealed 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;
/// <summary>
/// Executes the command asynchronously.
/// </summary>
/// <remarks>
/// This method can be awaited.
/// </remarks>
/// <exception cref="InvalidOperationException">The command failed to start.</exception>
/// <exception cref="CommandExecutionException">The executed command exits and the <see cref="Validation"/> was not met.</exception>
/// <exception cref="OperationCanceledException">The <paramref name="cancellationToken"/> was canceled and the process was killed.</exception>
public CommandTask<CommandResult> ExecuteAsync(CancellationToken cancellationToken = default)
{
var process = new Process
{
FileName = GetOptimallyQualifiedTargetFilePath(), //
Arguments = Arguments,
WorkingDirectory = WorkingDirPath,
};
foreach (var (key, value) in Environment)
{
if (value is not null)
{
process.Environment[key] = value;
}
else
{
// Null value means we should remove the variable
process.Environment.Remove(key);
}
}
if (!process.Start(out var exception))
{
var message = $"Failed to start a process with file path '{process.FileName}'. " +
$"Target file is not an executable or lacks execute permissions.";
throw new InvalidOperationException(message, exception);
}
// Extract the process ID before calling ExecuteAsync(), because the process may already be disposed by then.
var processId = process.Id;
var task = ExecuteAsync(process, cancellationToken);
return new CommandTask<CommandResult>(task, process, processId);
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 (System.Environment.GetEnvironmentVariable("PATH")?.Split(Path.PathSeparator) is { } paths)
{
foreach (var path in paths)
{
yield return path;
}
}
}
}
}
private async Task<CommandResult> ExecuteAsync(Process process, CancellationToken cancellationToken = default)
{
using var _ = process;
// timeout is triggered when the cancel timeout expires after we tried to stop the process
// -> release wait for exit and pumping tasks after that timeout
using var timeout = new CancellationTokenSource();
var stdout = PipeStdOutAsync(process, timeout.Token);
var stderr = PipeStdErrAsync(process, timeout.Token);
var stdin = PipeStdInAsync(process, timeout.Token);
var pump = Task.WhenAll(stdout, stderr, stdin);
await using var registration = cancellationToken.Register(Stop, (process, timeout));
// wait for the process to exit or cancellation to be requested when cancellation is requested,
// we try to stop the process and then wait for it to exit with a timeout.
// When the timeout expires, we cancel the pumping tasks as well as the wait for the process exit.
try
{
await process.WaitForExitAsync(CancellationToken.None).WaitAsync(timeout.Token);
}
catch (OperationCanceledException)
{
}
// we still wait for the pumping to complete but ignore cancellation here
try
{
await pump;
}
catch (OperationCanceledException)
{
}
// if cancellation was requested, throw after the process was tried to stop
cancellationToken.ThrowIfCancellationRequested();
if (process.ExitCode is 0 || !Validation.HasFlag(ValidationMode.ZeroExitCode))
{
return new CommandResult(process.ExitCode, process.StartTime, process.ExitTime);
}
var message = $"Command execution failed because the underlying process ({process.FileName}#{process.Id}) " +
$"returned a non-zero exit code ({process.ExitCode}).";
throw new CommandExecutionException(this, process.ExitCode, message);
static void Stop(object? state)
{
if (state is (Process process, CancellationTokenSource timeout))
{
timeout.CancelAfter(CancelWaitTimeout);
process.Kill();
}
}
}
private async Task PipeStdOutAsync(Process process, CancellationToken cancellationToken = default)
{
await using (process.StandardOutput)
{
await StandardOutputPipe.CopyFromAsync(process.StandardOutput, cancellationToken);
}
}
private async Task PipeStdErrAsync(Process process, CancellationToken cancellationToken = default)
{
await using (process.StandardError)
{
await StandardErrorPipe.CopyFromAsync(process.StandardError, cancellationToken);
}
}
private async Task PipeStdInAsync(Process 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 is 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,199 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Diagnostics.Contracts;
using System.Text;
namespace Geekeey.Process;
public sealed 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)
{
return source.WithStandardOutputPipe(target);
}
/// <summary>
/// Creates a new command that pipes its standard output to the specified stream.
/// </summary>
[Pure]
public static Command operator |(Command source, Stream target)
{
return source | PipeTarget.ToStream(target);
}
/// <summary>
/// Creates a new command that pipes its standard output to the specified string builder.
/// Uses <see cref="Console.OutputEncoding" /> for decoding.
/// </summary>
[Pure]
public static Command operator |(Command source, StringBuilder target)
{
return 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)
{
return 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)
{
return 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)
{
return 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)
{
return 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)
{
return 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)
{
var stdout = PipeTarget.ToStringBuilder(targets.stdOut, Console.OutputEncoding);
var stderr = PipeTarget.ToStringBuilder(targets.stdErr, Console.OutputEncoding);
return source | (stdout, stderr);
}
/// <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)
{
var stdout = PipeTarget.ToDelegate(targets.stdOut, Console.OutputEncoding);
var stderr = PipeTarget.ToDelegate(targets.stdErr, Console.OutputEncoding);
return source | (stdout, stderr);
}
/// <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)
{
var stdout = PipeTarget.ToDelegate(targets.stdOut, Console.OutputEncoding);
var stderr = PipeTarget.ToDelegate(targets.stdErr, Console.OutputEncoding);
return source | (stdout, stderr);
}
/// <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)
{
var stdout = PipeTarget.ToDelegate(targets.stdOut, Console.OutputEncoding);
var stderr = PipeTarget.ToDelegate(targets.stdErr, Console.OutputEncoding);
return source | (stdout, stderr);
}
/// <summary>
/// Creates a new command that pipes its standard input from the specified source.
/// </summary>
[Pure]
public static Command operator |(PipeSource source, Command target)
{
return 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)
{
return 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)
{
return 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)
{
return 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)
{
return 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)
{
return PipeSource.FromCommand(source) | target;
}
}

84
src/process/Command.cs Normal file
View file

@ -0,0 +1,84 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Diagnostics.CodeAnalysis;
namespace Geekeey.Process;
/// <summary>
/// Instructions for running a process.
/// </summary>
public sealed partial class Command
{
/// <summary>
/// Initializes an instance of <see cref="Command" />.
/// </summary>
public Command(string targetFilePath, string arguments, string workingDirPath,
IReadOnlyDictionary<string, string?> environment, ValidationMode validation,
PipeSource standardInputPipe, PipeTarget standardOutputPipe, PipeTarget standardErrorPipe)
{
TargetFilePath = targetFilePath;
Arguments = arguments;
WorkingDirPath = workingDirPath;
Environment = environment;
Validation = validation;
StandardInputPipe = standardInputPipe;
StandardOutputPipe = standardOutputPipe;
StandardErrorPipe = standardErrorPipe;
}
/// <summary>
/// Initializes an instance of <see cref="Command" />.
/// </summary>
public Command(string targetFilePath) : this(targetFilePath, string.Empty, Directory.GetCurrentDirectory(),
new Dictionary<string, string?>(), ValidationMode.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; }
/// <summary>
/// File path of the executable, batch file, or script, that this command runs.
/// </summary>
public string Arguments { get; }
/// <summary>
/// File path of the executable, batch file, or script, that this command runs.
/// </summary>
public string WorkingDirPath { get; }
/// <summary>
/// Environment variables set for the underlying process.
/// </summary>
public IReadOnlyDictionary<string, string?> Environment { get; }
/// <summary>
/// Strategy for validating the result of the execution.
/// </summary>
public ValidationMode Validation { get; }
/// <summary>
/// Pipe source for the standard input stream of the underlying process.
/// </summary>
public PipeSource StandardInputPipe { get; }
/// <summary>
/// Pipe target for the standard output stream of the underlying process.
/// </summary>
public PipeTarget StandardOutputPipe { get; }
/// <summary>
/// Pipe target for the standard error stream of the underlying process.
/// </summary>
public PipeTarget StandardErrorPipe { get; }
/// <inheritdoc />
[ExcludeFromCodeCoverage]
public override string ToString()
{
return $"{TargetFilePath} {Arguments}";
}
}

View file

@ -0,0 +1,30 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Process;
/// <summary>
/// Exception thrown when the command fails to execute correctly.
/// </summary>
public class CommandExecutionException : Exception
{
/// <summary>
/// Exception thrown when the command fails to execute correctly.
/// </summary>
public CommandExecutionException(Command command, int exitCode, string message, Exception? innerException = null)
: base(message, innerException)
{
Command = command;
ExitCode = exitCode;
}
/// <summary>
/// Command that triggered the exception.
/// </summary>
public Command Command { get; }
/// <summary>
/// Exit code returned by the process.
/// </summary>
public int ExitCode { get; }
}

View file

@ -0,0 +1,64 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Process;
/// <summary>
/// Represents the result of a command execution.
/// </summary>
public partial class CommandResult
{
/// <summary>
/// Represents the result of a command execution.
/// </summary>
public CommandResult(int exitCode, DateTimeOffset startTime, DateTimeOffset exitTime)
{
ExitCode = exitCode;
StartTime = startTime;
ExitTime = exitTime;
}
/// <summary>
/// Exit code set by the underlying process.
/// </summary>
public int ExitCode { get; }
/// <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; }
/// <summary>
/// Time at which the command finished executing.
/// </summary>
public DateTimeOffset ExitTime { get; }
/// <summary>
/// Total duration of the command execution.
/// </summary>
public TimeSpan RunTime => ExitTime - StartTime;
}
public partial class CommandResult
{
/// <summary>
/// Converts the result to an integer value that corresponds to the <see cref="CommandResult.ExitCode" /> property.
/// </summary>
public static implicit operator int(CommandResult result)
{
return result.ExitCode;
}
/// <summary>
/// Converts the result to a boolean value that corresponds to the <see cref="CommandResult.IsSuccess" /> property.
/// </summary>
public static implicit operator bool(CommandResult result)
{
return result.IsSuccess;
}
}

View file

@ -0,0 +1,94 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Runtime.CompilerServices;
namespace Geekeey.Process;
/// <summary>
/// Represents an asynchronous execution of a command.
/// </summary>
public partial class CommandTask<TResult> : IDisposable
{
private readonly Process _process;
internal CommandTask(Task<TResult> task, Process process, int processId)
{
Task = task;
_process = process;
ProcessId = processId;
}
/// <summary>
/// Underlying task.
/// </summary>
public Task<TResult> Task { get; }
/// <summary>
/// Underlying process ID.
/// </summary>
public int ProcessId { get; }
internal CommandTask<T> Bind<T>(Func<Task<TResult>, Task<T>> transform)
{
return new CommandTask<T>(transform(Task), _process, ProcessId);
}
/// <summary>
/// Lazily maps the result of the task using the specified transform.
/// </summary>
internal CommandTask<T> Select<T>(Func<TResult, T> transform)
{
return Bind(async task => transform(await task));
}
/// <summary>
/// Signals the process with an interrupt request from the keyboard.
/// </summary>
public void Interrupt()
{
_process.Interrupt();
}
/// <summary>
/// Immediately stops the associated process and its descendent processes.
/// </summary>
public void Kill()
{
_process.Kill();
}
/// <summary>
/// Gets the awaiter of the underlying task.
/// Used to enable await expressions on this object.
/// </summary>
public TaskAwaiter<TResult> GetAwaiter()
{
return Task.GetAwaiter();
}
/// <summary>
/// Configures an awaiter used to await this task.
/// </summary>
public ConfiguredTaskAwaitable<TResult> ConfigureAwait(bool continueOnCapturedContext)
{
return 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)
{
return commandTask.Task;
}
}

View file

@ -0,0 +1,50 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.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)
{
return Set((IEnumerable<KeyValuePair<string, string?>>)variables);
}
/// <summary>
/// Builds the resulting environment variables.
/// </summary>
public IReadOnlyDictionary<string, string?> Build()
{
return new Dictionary<string, string?>(_vars, _vars.Comparer);
}
}

View file

@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>true</IsPackable>
</PropertyGroup>
<PropertyGroup>
<RootNamespace>Geekeey.Process</RootNamespace>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<!-- required because of native library import for libc -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup Condition="'$(Configuration)' == 'Debug'">
<InternalsVisibleTo Include="$(AssemblyName).Tests" />
</ItemGroup>
<PropertyGroup>
<PackageReadmeFile>package-readme.md</PackageReadmeFile>
<PackageIcon>package-icon.png</PackageIcon>
<PackageProjectUrl>https://code.geekeey.de/geekeey/process/src/branch/main/src/process</PackageProjectUrl>
<PackageLicenseExpression>EUPL-1.2</PackageLicenseExpression>
</PropertyGroup>
<ItemGroup>
<None Include=".\package-icon.png" Pack="true" PackagePath="\" Visible="false" />
<None Include=".\package-readme.md" Pack="true" PackagePath="\" Visible="false" />
<None Include="..\..\LICENSE.md" Pack="true" PackagePath="\" Visible="false" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,134 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Buffers;
using System.Diagnostics.CodeAnalysis;
namespace Geekeey.Process;
internal sealed class MemoryBufferStream : Stream
{
public const int DefaultBufferSize = 81920;
private readonly SemaphoreSlim _writeLock = new(1, 1);
private readonly SemaphoreSlim _readLock = new(0, 1);
private IMemoryOwner<byte> _sharedBuffer = MemoryPool<byte>.Shared.Rent(DefaultBufferSize);
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)
{
return ReadAsync(buffer, offset, count).GetAwaiter().GetResult();
}
[ExcludeFromCodeCoverage]
public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
return 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();
}
}

124
src/process/PipeSource.cs Normal file
View file

@ -0,0 +1,124 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Text;
namespace Geekeey.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 sealed class AnonymousPipeSource(Func<Stream, CancellationToken, Task> func) : PipeSource
{
public override async Task CopyToAsync(Stream destination, CancellationToken cancellationToken = default)
{
await func(destination, cancellationToken);
}
}
}
public abstract 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)
{
return 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)
{
return 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)
{
return Create(stream.CopyToAsync);
}
/// <summary>
/// Creates a pipe source that reads from the specified file.
/// </summary>
public static PipeSource FromFile(string filePath)
{
return 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)
{
return 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)
{
return FromBytes((ReadOnlyMemory<byte>)data);
}
/// <summary>
/// Creates a pipe source that reads from the specified string.
/// </summary>
public static PipeSource FromString(string str, Encoding encoding)
{
return 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)
{
return 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)
{
return Create(async (destination, cancellationToken) =>
await command.WithStandardOutputPipe(PipeTarget.ToStream(destination)).ExecuteAsync(cancellationToken));
}
}

314
src/process/PipeTarget.cs Normal file
View file

@ -0,0 +1,314 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Buffers;
using System.Text;
namespace Geekeey.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 const int DefaultBufferSize = 1024;
private sealed class AnonymousPipeTarget(Func<Stream, CancellationToken, Task> func) : PipeTarget
{
public override async Task CopyFromAsync(Stream origin, CancellationToken cancellationToken = default)
{
await func(origin, cancellationToken);
}
}
private sealed 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(MemoryBufferStream.DefaultBufferSize);
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)
{
return 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)
{
return 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)
{
return 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)
{
return 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)
{
return Create(async (origin, cancellationToken) =>
{
using var reader = new StreamReader(origin, encoding, false, DefaultBufferSize, true);
using var buffer = MemoryPool<char>.Shared.Rent(DefaultBufferSize);
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)
{
return 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)
{
return Create(async (origin, cancellationToken) =>
{
using var reader = new StreamReader(origin, encoding, false, DefaultBufferSize, 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)
{
return 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)
{
return 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)
{
return 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)
{
return 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)
{
return ToDelegate(action, Console.OutputEncoding);
}
/// <summary>
/// Creates a pipe target that replicates data over multiple inner targets.
/// </summary>
public static PipeTarget Merge(params 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);
}
}
}
}
}

View file

@ -0,0 +1,46 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
namespace Geekeey.Process;
internal sealed partial class Process
{
[SupportedOSPlatform("freebsd")]
[SupportedOSPlatform("linux")]
[SupportedOSPlatform("macOS")]
private static bool SendPosixSignal(int pid, PosixSignals signal)
{
return Posix.Kill(pid, (int)signal) is 0;
}
[SupportedOSPlatform("freebsd")]
[SupportedOSPlatform("linux")]
[SupportedOSPlatform("macOS")]
internal static partial class Posix
{
[LibraryImport("libc", EntryPoint = "kill", SetLastError = true)]
internal static partial int Kill(int pid, int sig);
}
private enum PosixSignals : int
{
SIGHUP = 1,
SIGINT = 2,
SIGQUIT = 3,
SIGILL = 4,
SIGTRAP = 5,
SIGABRT = 6,
SIGBUS = 7,
SIGFPE = 8,
SIGKILL = 9,
SIGUSR1 = 10,
SIGSEGV = 11,
SIGUSR2 = 12,
SIGPIPE = 13,
SIGALRM = 14,
SIGTERM = 15,
}
}

View file

@ -0,0 +1,33 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
namespace Geekeey.Process;
internal sealed partial class Process
{
[SupportedOSPlatform("windows")]
private static bool SendCtrlSignal(int processId, ConsoleCtrlEvent ctrl)
{
return Windows.GenerateConsoleCtrlEvent((uint)ctrl, (uint)processId);
}
[SupportedOSPlatform("windows")]
internal static partial class Windows
{
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool GenerateConsoleCtrlEvent(uint dwCtrlEvent, uint dwProcessGroupId);
}
internal enum ConsoleCtrlEvent : uint
{
CTRL_C_EVENT = 0, // SIGINT
CTRL_BREAK_EVENT = 1, // SIGQUIT
CTRL_CLOSE_EVENT = 2, // SIGHUP
CTRL_LOGOFF_EVENT = 5, // SIGHUP
CTRL_SHUTDOWN_EVENT = 6, // SIGTERM
}
}

181
src/process/Process.cs Normal file
View file

@ -0,0 +1,181 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.ComponentModel;
using System.Diagnostics;
namespace Geekeey.Process;
internal sealed partial class Process : IDisposable
{
private readonly TaskCompletionSource _exit = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly System.Diagnostics.Process _process = new();
public Process()
{
// Redirect all standard streams
_process.StartInfo.RedirectStandardInput = true;
_process.StartInfo.RedirectStandardOutput = true;
_process.StartInfo.RedirectStandardError = true;
// Do not use the system shell to start the process
_process.StartInfo.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 to be able to send signals to one specific child process,
// without affecting any others that may also be running in parallel.
_process.StartInfo.CreateNoWindow = true;
// Only create a new process group on windows to allow sending ctrl-c/ctrl-break signals
// without affecting ourselves. This has the implication that the spawned process might not handle
// the ctrl-c/ctrl-break signals any more because the process is launched with the CREATE_NEW_PROCESS_GROUP flag.
// This is because it disables the default ctrl-c handling for the process.
// The process must reenable this behavior itself with a call to `SetConsoleCtrlHandler(null, false)`.
// > "If the HandlerRoutine parameter is NULL, a TRUE value causes the calling process to ignore CTRL+C input,
// > and a FALSE value restores normal processing of CTRL+C input.
// > This attribute of ignoring or processing CTRL+C is inherited by child processes."
if (OperatingSystem.IsWindows())
{
_process.StartInfo.CreateNewProcessGroup = true;
}
}
public int Id => _process.Id;
public string FileName
{
get => _process.StartInfo.FileName;
init => _process.StartInfo.FileName = value;
}
public string Arguments
{
get => _process.StartInfo.Arguments;
init => _process.StartInfo.Arguments = value;
}
public string WorkingDirectory
{
get => _process.StartInfo.WorkingDirectory;
init => _process.StartInfo.WorkingDirectory = value;
}
public IDictionary<string, string?> Environment => _process.StartInfo.Environment;
// 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.StartInfo.RedirectStandardInput ? _process.StandardInput.BaseStream : Stream.Null;
public Stream StandardOutput => _process.StartInfo.RedirectStandardOutput ? _process.StandardOutput.BaseStream : Stream.Null;
public Stream StandardError => _process.StartInfo.RedirectStandardError ? _process.StandardError.BaseStream : Stream.Null;
// 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 bool Start(out Exception? exception)
{
exception = null;
_process.EnableRaisingEvents = true;
_process.Exited += OnProcessExited;
try
{
if (!_process.Start())
{
return false;
}
StartTime = DateTimeOffset.Now;
}
catch (Win32Exception value)
{
exception = value;
return false;
}
return true;
void OnProcessExited(object? _, EventArgs args)
{
_process.Exited -= OnProcessExited;
ExitTime = DateTimeOffset.Now;
_exit.TrySetResult();
}
}
public void Interrupt()
{
if (TryInterrupt())
{
return;
}
// In case of failure, revert to the default behavior of killing the process.
// Ideally, we should throw an exception here, but this method is called from
// a cancellation callback, which would prevent other callbacks from being called.
Kill();
Debug.Fail("Failed to send an interrupt signal.");
return;
bool TryInterrupt()
{
try
{
if (OperatingSystem.IsWindows())
{
return SendCtrlSignal(_process.Id, ConsoleCtrlEvent.CTRL_C_EVENT) ||
SendCtrlSignal(_process.Id, ConsoleCtrlEvent.CTRL_BREAK_EVENT);
}
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS() || OperatingSystem.IsFreeBSD())
{
return SendPosixSignal(_process.Id, PosixSignals.SIGINT) ||
SendPosixSignal(_process.Id, PosixSignals.SIGQUIT);
}
// Unsupported platform
return false;
}
catch
{
return false;
}
}
}
public void Kill()
{
try
{
_process.Kill(entireProcessTree: 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 WaitForExitAsync(CancellationToken cancellationToken = default)
{
await _exit.Task.WaitAsync(cancellationToken);
}
public void Dispose()
{
_process.Dispose();
}
}

View file

@ -0,0 +1,21 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Process;
/// <summary>
/// Strategy used for validating the result of a command execution.
/// </summary>
[Flags]
public enum ValidationMode
{
/// <summary>
/// No validation.
/// </summary>
None = 0b0,
/// <summary>
/// Ensure that the command returned a zero exit code.
/// </summary>
ZeroExitCode = 0b1,
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,51 @@
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.
## Usage
### Execute a command and capturing its output:
```csharp
public static async Task<int> Main()
{
var stdout = new StringBuilder();
var cmd = new Command("git").WithArguments(["config", "--get", "user.name"]) | stdout;
await cmd.ExecuteAsync();
Console.WriteLine(stdout.ToString());
return 0;
}
```
### Execute a command and redirect its output to another command:
```csharp
public static Task<int> Main()
{
var cmd = new Command("cat").WithArguments(["file.txt"]) | new Command("wc");
await cmd.ExecuteAsync();
Console.WriteLine(stdout.ToString());
}
```
### Execute a command with cancellation support:
```csharp
public static async Task<int> Main()
{
using var cts = new CancellationTokenSource();
Console.CancelKeyPress += (s, e) =>
{
e.Cancel = true;
cts.Cancel();
};
var cmd = new Command("long-running-command");
// kills the process if Ctrl+C is pressed
var app = cmd.ExecuteAsync(cts.Token);
// manually interrupt after 5 seconds
await Task.Delay(5000);
app.Interrupt();
// wait for process to exit
var result = await app;
return 0;
}
```