feat: initial project commit
All checks were successful
default / default (8.0) (push) Successful in 39s
All checks were successful
default / default (8.0) (push) Successful in 39s
This commit is contained in:
commit
833bc2bd9c
52 changed files with 3783 additions and 0 deletions
447
.editorconfig
Normal file
447
.editorconfig
Normal file
|
@ -0,0 +1,447 @@
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_style = tab
|
||||||
|
indent_size = 4
|
||||||
|
tab_width = 4
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = false
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[.forgejo/workflows/*.yml]
|
||||||
|
indent_size = 2
|
||||||
|
indent_style = space
|
||||||
|
|
||||||
|
[*.{xml,csproj,props,targets}]
|
||||||
|
indent_size = 2
|
||||||
|
indent_style = space
|
||||||
|
|
||||||
|
[nuget.config]
|
||||||
|
indent_size = 2
|
||||||
|
indent_style = space
|
||||||
|
|
||||||
|
[*.{cs,vb}] #### .NET Coding Conventions ####
|
||||||
|
|
||||||
|
# Organize usings
|
||||||
|
dotnet_separate_import_directive_groups = true
|
||||||
|
dotnet_sort_system_directives_first = true
|
||||||
|
file_header_template = unset
|
||||||
|
|
||||||
|
# this. and Me. preferences
|
||||||
|
dotnet_style_qualification_for_event = false:silent
|
||||||
|
dotnet_style_qualification_for_field = false:silent
|
||||||
|
dotnet_style_qualification_for_method = false:silent
|
||||||
|
dotnet_style_qualification_for_property = false:silent
|
||||||
|
|
||||||
|
# Language keywords vs BCL types preferences
|
||||||
|
dotnet_style_predefined_type_for_locals_parameters_members = true:silent
|
||||||
|
dotnet_style_predefined_type_for_member_access = true:silent
|
||||||
|
|
||||||
|
# Parentheses preferences
|
||||||
|
dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent
|
||||||
|
dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent
|
||||||
|
dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent
|
||||||
|
dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent
|
||||||
|
|
||||||
|
# Modifier preferences
|
||||||
|
dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent
|
||||||
|
|
||||||
|
# Expression-level preferences
|
||||||
|
dotnet_style_coalesce_expression = true:suggestion
|
||||||
|
dotnet_style_collection_initializer = true:suggestion
|
||||||
|
dotnet_style_explicit_tuple_names = true:suggestion
|
||||||
|
dotnet_style_null_propagation = true:suggestion
|
||||||
|
dotnet_style_object_initializer = true:suggestion
|
||||||
|
dotnet_style_operator_placement_when_wrapping = beginning_of_line
|
||||||
|
dotnet_style_prefer_auto_properties = true:suggestion
|
||||||
|
dotnet_style_prefer_compound_assignment = true:suggestion
|
||||||
|
dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion
|
||||||
|
dotnet_style_prefer_conditional_expression_over_return = true:suggestion
|
||||||
|
dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
|
||||||
|
dotnet_style_prefer_inferred_tuple_names = true:suggestion
|
||||||
|
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
|
||||||
|
dotnet_style_prefer_simplified_boolean_expressions = true:suggestion
|
||||||
|
dotnet_style_prefer_simplified_interpolation = true:suggestion
|
||||||
|
dotnet_style_namespace_match_folder = false # resharper
|
||||||
|
|
||||||
|
# Field preferences
|
||||||
|
dotnet_style_readonly_field = true:warning
|
||||||
|
|
||||||
|
# Parameter preferences
|
||||||
|
dotnet_code_quality_unused_parameters = all:suggestion
|
||||||
|
|
||||||
|
# Suppression preferences
|
||||||
|
dotnet_remove_unnecessary_suppression_exclusions = none
|
||||||
|
|
||||||
|
# ReSharper preferences
|
||||||
|
resharper_check_namespace_highlighting = none
|
||||||
|
|
||||||
|
[*.cs] #### C# Coding Conventions ####
|
||||||
|
|
||||||
|
# var preferences
|
||||||
|
csharp_style_var_for_built_in_types = true:suggestion
|
||||||
|
csharp_style_var_when_type_is_apparent = true:suggestion
|
||||||
|
csharp_style_var_elsewhere = true:suggestion
|
||||||
|
|
||||||
|
# Expression-bodied members
|
||||||
|
csharp_style_expression_bodied_accessors = true:silent
|
||||||
|
csharp_style_expression_bodied_constructors = false:silent
|
||||||
|
csharp_style_expression_bodied_indexers = true:silent
|
||||||
|
csharp_style_expression_bodied_lambdas = true:suggestion
|
||||||
|
csharp_style_expression_bodied_local_functions = false:silent
|
||||||
|
csharp_style_expression_bodied_methods = false:silent
|
||||||
|
csharp_style_expression_bodied_operators = false:silent
|
||||||
|
csharp_style_expression_bodied_properties = true:silent
|
||||||
|
|
||||||
|
# Pattern matching preferences
|
||||||
|
csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
|
||||||
|
csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
|
||||||
|
csharp_style_prefer_not_pattern = true:suggestion
|
||||||
|
csharp_style_prefer_pattern_matching = true:silent
|
||||||
|
csharp_style_prefer_switch_expression = true:suggestion
|
||||||
|
|
||||||
|
# Null-checking preferences
|
||||||
|
csharp_style_conditional_delegate_call = true:suggestion
|
||||||
|
|
||||||
|
# Modifier preferences
|
||||||
|
csharp_prefer_static_local_function = true:warning
|
||||||
|
csharp_preferred_modifier_order = public, private, protected, internal, static, extern, new, virtual, abstract, sealed, override, readonly, unsafe, volatile, async:silent
|
||||||
|
|
||||||
|
# Code-block preferences
|
||||||
|
csharp_prefer_braces = true:silent
|
||||||
|
csharp_prefer_simple_using_statement = true:suggestion
|
||||||
|
|
||||||
|
# Expression-level preferences
|
||||||
|
csharp_prefer_simple_default_expression = true:suggestion
|
||||||
|
csharp_style_deconstructed_variable_declaration = true:suggestion
|
||||||
|
csharp_style_inlined_variable_declaration = true:suggestion
|
||||||
|
csharp_style_pattern_local_over_anonymous_function = true:suggestion
|
||||||
|
csharp_style_prefer_index_operator = true:suggestion
|
||||||
|
csharp_style_prefer_range_operator = true:suggestion
|
||||||
|
csharp_style_throw_expression = true:suggestion
|
||||||
|
csharp_style_unused_value_assignment_preference = discard_variable:suggestion
|
||||||
|
csharp_style_unused_value_expression_statement_preference = discard_variable:silent
|
||||||
|
|
||||||
|
# 'using' directive preferences
|
||||||
|
csharp_using_directive_placement = outside_namespace:silent
|
||||||
|
|
||||||
|
# 'namespace' preferences
|
||||||
|
csharp_style_namespace_declarations = file_scoped:warning
|
||||||
|
|
||||||
|
[*.cs] #### C# Formatting Rules ####
|
||||||
|
|
||||||
|
# New line preferences
|
||||||
|
csharp_new_line_before_catch = true
|
||||||
|
csharp_new_line_before_else = true
|
||||||
|
csharp_new_line_before_finally = true
|
||||||
|
csharp_new_line_before_members_in_anonymous_types = true
|
||||||
|
csharp_new_line_before_members_in_object_initializers = true
|
||||||
|
csharp_new_line_before_open_brace = all
|
||||||
|
csharp_new_line_between_query_expression_clauses = true
|
||||||
|
|
||||||
|
# Indentation preferences
|
||||||
|
csharp_indent_block_contents = true
|
||||||
|
csharp_indent_braces = false
|
||||||
|
csharp_indent_case_contents = true
|
||||||
|
csharp_indent_case_contents_when_block = true
|
||||||
|
csharp_indent_labels = one_less_than_current
|
||||||
|
csharp_indent_switch_labels = true
|
||||||
|
|
||||||
|
# Space preferences
|
||||||
|
csharp_space_after_cast = false
|
||||||
|
csharp_space_after_colon_in_inheritance_clause = true
|
||||||
|
csharp_space_after_comma = true
|
||||||
|
csharp_space_after_dot = false
|
||||||
|
csharp_space_after_keywords_in_control_flow_statements = true
|
||||||
|
csharp_space_after_semicolon_in_for_statement = true
|
||||||
|
csharp_space_around_binary_operators = before_and_after
|
||||||
|
csharp_space_around_declaration_statements = false
|
||||||
|
csharp_space_before_colon_in_inheritance_clause = true
|
||||||
|
csharp_space_before_comma = false
|
||||||
|
csharp_space_before_dot = false
|
||||||
|
csharp_space_before_open_square_brackets = false
|
||||||
|
csharp_space_before_semicolon_in_for_statement = false
|
||||||
|
csharp_space_between_empty_square_brackets = false
|
||||||
|
csharp_space_between_method_call_empty_parameter_list_parentheses = false
|
||||||
|
csharp_space_between_method_call_name_and_opening_parenthesis = false
|
||||||
|
csharp_space_between_method_call_parameter_list_parentheses = false
|
||||||
|
csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
|
||||||
|
csharp_space_between_method_declaration_name_and_open_parenthesis = false
|
||||||
|
csharp_space_between_method_declaration_parameter_list_parentheses = false
|
||||||
|
csharp_space_between_parentheses = false
|
||||||
|
csharp_space_between_square_brackets = false
|
||||||
|
|
||||||
|
# Wrapping preferences
|
||||||
|
csharp_preserve_single_line_blocks = true
|
||||||
|
csharp_preserve_single_line_statements = true
|
||||||
|
|
||||||
|
[*.{cs,vb}] #### .NET Naming styles ####
|
||||||
|
|
||||||
|
dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.severity = suggestion
|
||||||
|
dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.symbols = types_and_namespaces
|
||||||
|
dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.style = pascalcase
|
||||||
|
|
||||||
|
dotnet_naming_symbols.types_and_namespaces.applicable_kinds = namespace, class, struct, interface, enum
|
||||||
|
dotnet_naming_symbols.types_and_namespaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
|
||||||
|
dotnet_naming_symbols.types_and_namespaces.required_modifiers =
|
||||||
|
|
||||||
|
|
||||||
|
dotnet_naming_rule.interfaces_should_be_ipascalcase.severity = suggestion
|
||||||
|
dotnet_naming_rule.interfaces_should_be_ipascalcase.symbols = interfaces
|
||||||
|
dotnet_naming_rule.interfaces_should_be_ipascalcase.style = ipascalcase
|
||||||
|
|
||||||
|
dotnet_naming_symbols.interfaces.applicable_kinds = interface
|
||||||
|
dotnet_naming_symbols.interfaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
|
||||||
|
dotnet_naming_symbols.interfaces.required_modifiers =
|
||||||
|
|
||||||
|
|
||||||
|
dotnet_naming_rule.enums_should_be_pascalcase.severity = suggestion
|
||||||
|
dotnet_naming_rule.enums_should_be_pascalcase.symbols = enums
|
||||||
|
dotnet_naming_rule.enums_should_be_pascalcase.style = pascalcase
|
||||||
|
|
||||||
|
dotnet_naming_symbols.enums.applicable_kinds = enum
|
||||||
|
dotnet_naming_symbols.enums.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
|
||||||
|
dotnet_naming_symbols.enums.required_modifiers =
|
||||||
|
|
||||||
|
|
||||||
|
dotnet_naming_rule.type_parameters_should_be_tpascalcase.severity = suggestion
|
||||||
|
dotnet_naming_rule.type_parameters_should_be_tpascalcase.symbols = type_parameters
|
||||||
|
dotnet_naming_rule.type_parameters_should_be_tpascalcase.style = tpascalcase
|
||||||
|
|
||||||
|
dotnet_naming_symbols.type_parameters.applicable_kinds = namespace
|
||||||
|
dotnet_naming_symbols.type_parameters.applicable_accessibilities = *
|
||||||
|
dotnet_naming_symbols.type_parameters.required_modifiers =
|
||||||
|
|
||||||
|
|
||||||
|
dotnet_naming_rule.methods_should_be_pascalcase.severity = suggestion
|
||||||
|
dotnet_naming_rule.methods_should_be_pascalcase.symbols = methods
|
||||||
|
dotnet_naming_rule.methods_should_be_pascalcase.style = pascalcase
|
||||||
|
|
||||||
|
dotnet_naming_symbols.methods.applicable_kinds = method
|
||||||
|
dotnet_naming_symbols.methods.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
|
||||||
|
dotnet_naming_symbols.methods.required_modifiers =
|
||||||
|
|
||||||
|
|
||||||
|
dotnet_naming_rule.async_methods_end_in_async.severity = suggestion
|
||||||
|
dotnet_naming_rule.async_methods_end_in_async.symbols = any_async_methods
|
||||||
|
dotnet_naming_rule.async_methods_end_in_async.style = end_in_async
|
||||||
|
|
||||||
|
dotnet_naming_symbols.any_async_methods.applicable_kinds = method
|
||||||
|
dotnet_naming_symbols.any_async_methods.applicable_accessibilities = *
|
||||||
|
dotnet_naming_symbols.any_async_methods.required_modifiers = async
|
||||||
|
|
||||||
|
|
||||||
|
dotnet_naming_rule.parameters_should_be_camelcase.severity = suggestion
|
||||||
|
dotnet_naming_rule.parameters_should_be_camelcase.symbols = parameters
|
||||||
|
dotnet_naming_rule.parameters_should_be_camelcase.style = camelcase
|
||||||
|
|
||||||
|
dotnet_naming_symbols.parameters.applicable_kinds = parameter
|
||||||
|
dotnet_naming_symbols.parameters.applicable_accessibilities = *
|
||||||
|
dotnet_naming_symbols.parameters.required_modifiers =
|
||||||
|
|
||||||
|
|
||||||
|
dotnet_naming_rule.properties_should_be_pascalcase.severity = suggestion
|
||||||
|
dotnet_naming_rule.properties_should_be_pascalcase.symbols = properties
|
||||||
|
dotnet_naming_rule.properties_should_be_pascalcase.style = pascalcase
|
||||||
|
|
||||||
|
dotnet_naming_symbols.properties.applicable_kinds = property
|
||||||
|
dotnet_naming_symbols.properties.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
|
||||||
|
dotnet_naming_symbols.properties.required_modifiers =
|
||||||
|
|
||||||
|
|
||||||
|
dotnet_naming_rule.events_should_be_pascalcase.severity = suggestion
|
||||||
|
dotnet_naming_rule.events_should_be_pascalcase.symbols = events
|
||||||
|
dotnet_naming_rule.events_should_be_pascalcase.style = pascalcase
|
||||||
|
|
||||||
|
dotnet_naming_symbols.events.applicable_kinds = event
|
||||||
|
dotnet_naming_symbols.events.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
|
||||||
|
dotnet_naming_symbols.events.required_modifiers =
|
||||||
|
|
||||||
|
# local
|
||||||
|
|
||||||
|
dotnet_naming_rule.local_variables_should_be_camelcase.severity = suggestion
|
||||||
|
dotnet_naming_rule.local_variables_should_be_camelcase.symbols = local_variables
|
||||||
|
dotnet_naming_rule.local_variables_should_be_camelcase.style = camelcase
|
||||||
|
|
||||||
|
dotnet_naming_symbols.local_variables.applicable_kinds = local
|
||||||
|
dotnet_naming_symbols.local_variables.applicable_accessibilities = local
|
||||||
|
dotnet_naming_symbols.local_variables.required_modifiers =
|
||||||
|
|
||||||
|
|
||||||
|
dotnet_naming_rule.local_functions_should_be_pascalcase.severity = suggestion
|
||||||
|
dotnet_naming_rule.local_functions_should_be_pascalcase.symbols = local_functions
|
||||||
|
dotnet_naming_rule.local_functions_should_be_pascalcase.style = pascalcase
|
||||||
|
|
||||||
|
dotnet_naming_symbols.local_functions.applicable_kinds = local_function
|
||||||
|
dotnet_naming_symbols.local_functions.applicable_accessibilities = *
|
||||||
|
dotnet_naming_symbols.local_functions.required_modifiers =
|
||||||
|
|
||||||
|
|
||||||
|
dotnet_naming_rule.local_constants_should_be_camelcase.severity = suggestion
|
||||||
|
dotnet_naming_rule.local_constants_should_be_camelcase.symbols = local_constants
|
||||||
|
dotnet_naming_rule.local_constants_should_be_camelcase.style = camelcase
|
||||||
|
|
||||||
|
dotnet_naming_symbols.local_constants.applicable_kinds = local
|
||||||
|
dotnet_naming_symbols.local_constants.applicable_accessibilities = local
|
||||||
|
dotnet_naming_symbols.local_constants.required_modifiers = const
|
||||||
|
|
||||||
|
# private
|
||||||
|
|
||||||
|
dotnet_naming_rule.private_fields_should_be__camelcase.severity = suggestion
|
||||||
|
dotnet_naming_rule.private_fields_should_be__camelcase.symbols = private_fields
|
||||||
|
dotnet_naming_rule.private_fields_should_be__camelcase.style = _camelcase
|
||||||
|
|
||||||
|
dotnet_naming_symbols.private_fields.applicable_kinds = field
|
||||||
|
dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
|
||||||
|
dotnet_naming_symbols.private_fields.required_modifiers =
|
||||||
|
|
||||||
|
|
||||||
|
dotnet_naming_rule.private_static_fields_should_be_s_camelcase.severity = suggestion
|
||||||
|
dotnet_naming_rule.private_static_fields_should_be_s_camelcase.symbols = private_static_fields
|
||||||
|
dotnet_naming_rule.private_static_fields_should_be_s_camelcase.style = s_camelcase
|
||||||
|
|
||||||
|
dotnet_naming_symbols.private_static_fields.applicable_kinds = field
|
||||||
|
dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
|
||||||
|
dotnet_naming_symbols.private_static_fields.required_modifiers = static
|
||||||
|
|
||||||
|
|
||||||
|
dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.severity = suggestion
|
||||||
|
dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.symbols = private_static_readonly_fields
|
||||||
|
dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.style = pascalcase
|
||||||
|
|
||||||
|
dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field
|
||||||
|
dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
|
||||||
|
dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = readonly, static
|
||||||
|
|
||||||
|
|
||||||
|
dotnet_naming_rule.private_constant_fields_should_be_pascalcase.severity = suggestion
|
||||||
|
dotnet_naming_rule.private_constant_fields_should_be_pascalcase.symbols = private_constant_fields
|
||||||
|
dotnet_naming_rule.private_constant_fields_should_be_pascalcase.style = pascalcase
|
||||||
|
|
||||||
|
dotnet_naming_symbols.private_constant_fields.applicable_kinds = field
|
||||||
|
dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
|
||||||
|
dotnet_naming_symbols.private_constant_fields.required_modifiers = const
|
||||||
|
|
||||||
|
# public
|
||||||
|
|
||||||
|
dotnet_naming_rule.public_fields_should_be_pascalcase.severity = suggestion
|
||||||
|
dotnet_naming_rule.public_fields_should_be_pascalcase.symbols = public_fields
|
||||||
|
dotnet_naming_rule.public_fields_should_be_pascalcase.style = pascalcase
|
||||||
|
|
||||||
|
dotnet_naming_symbols.public_fields.applicable_kinds = field
|
||||||
|
dotnet_naming_symbols.public_fields.applicable_accessibilities = public, internal
|
||||||
|
dotnet_naming_symbols.public_fields.required_modifiers =
|
||||||
|
|
||||||
|
|
||||||
|
dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.severity = suggestion
|
||||||
|
dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.symbols = public_static_readonly_fields
|
||||||
|
dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.style = pascalcase
|
||||||
|
|
||||||
|
dotnet_naming_symbols.public_static_readonly_fields.applicable_kinds = field
|
||||||
|
dotnet_naming_symbols.public_static_readonly_fields.applicable_accessibilities = public, internal
|
||||||
|
dotnet_naming_symbols.public_static_readonly_fields.required_modifiers = readonly, static
|
||||||
|
|
||||||
|
|
||||||
|
dotnet_naming_rule.public_constant_fields_should_be_pascalcase.severity = suggestion
|
||||||
|
dotnet_naming_rule.public_constant_fields_should_be_pascalcase.symbols = public_constant_fields
|
||||||
|
dotnet_naming_rule.public_constant_fields_should_be_pascalcase.style = pascalcase
|
||||||
|
|
||||||
|
dotnet_naming_symbols.public_constant_fields.applicable_kinds = field
|
||||||
|
dotnet_naming_symbols.public_constant_fields.applicable_accessibilities = public, internal
|
||||||
|
dotnet_naming_symbols.public_constant_fields.required_modifiers = const
|
||||||
|
|
||||||
|
# others
|
||||||
|
|
||||||
|
dotnet_naming_rule.non_field_members_should_be_pascalcase.severity = suggestion
|
||||||
|
dotnet_naming_rule.non_field_members_should_be_pascalcase.symbols = non_field_members
|
||||||
|
dotnet_naming_rule.non_field_members_should_be_pascalcase.style = pascalcase
|
||||||
|
|
||||||
|
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
|
||||||
|
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
|
||||||
|
dotnet_naming_symbols.non_field_members.required_modifiers =
|
||||||
|
|
||||||
|
|
||||||
|
# Naming styles
|
||||||
|
|
||||||
|
dotnet_naming_style.pascalcase.required_prefix =
|
||||||
|
dotnet_naming_style.pascalcase.required_suffix =
|
||||||
|
dotnet_naming_style.pascalcase.word_separator =
|
||||||
|
dotnet_naming_style.pascalcase.capitalization = pascal_case
|
||||||
|
|
||||||
|
dotnet_naming_style.ipascalcase.required_prefix = I
|
||||||
|
dotnet_naming_style.ipascalcase.required_suffix =
|
||||||
|
dotnet_naming_style.ipascalcase.word_separator =
|
||||||
|
dotnet_naming_style.ipascalcase.capitalization = pascal_case
|
||||||
|
|
||||||
|
dotnet_naming_style.tpascalcase.required_prefix = T
|
||||||
|
dotnet_naming_style.tpascalcase.required_suffix =
|
||||||
|
dotnet_naming_style.tpascalcase.word_separator =
|
||||||
|
dotnet_naming_style.tpascalcase.capitalization = pascal_case
|
||||||
|
|
||||||
|
dotnet_naming_style._camelcase.required_prefix = _
|
||||||
|
dotnet_naming_style._camelcase.required_suffix =
|
||||||
|
dotnet_naming_style._camelcase.word_separator =
|
||||||
|
dotnet_naming_style._camelcase.capitalization = camel_case
|
||||||
|
|
||||||
|
dotnet_naming_style.camelcase.required_prefix =
|
||||||
|
dotnet_naming_style.camelcase.required_suffix =
|
||||||
|
dotnet_naming_style.camelcase.word_separator =
|
||||||
|
dotnet_naming_style.camelcase.capitalization = camel_case
|
||||||
|
|
||||||
|
dotnet_naming_style.s_camelcase.required_prefix = s_
|
||||||
|
dotnet_naming_style.s_camelcase.required_suffix =
|
||||||
|
dotnet_naming_style.s_camelcase.word_separator =
|
||||||
|
dotnet_naming_style.s_camelcase.capitalization = camel_case
|
||||||
|
|
||||||
|
dotnet_naming_style.end_in_async.required_prefix =
|
||||||
|
dotnet_naming_style.end_in_async.required_suffix = Async
|
||||||
|
dotnet_naming_style.end_in_async.capitalization = pascal_case
|
||||||
|
dotnet_naming_style.end_in_async.word_separator =
|
||||||
|
|
||||||
|
[*.{cs.vb}]
|
||||||
|
dotnet_diagnostic.IDE0055.severity = error
|
||||||
|
# IDE0051: Remove unused private member
|
||||||
|
dotnet_diagnostic.IDE0051.severity = error
|
||||||
|
# IDE0052: Remove unread private member
|
||||||
|
dotnet_diagnostic.IDE0052.severity = error
|
||||||
|
# IDE0064: Make struct fields writable
|
||||||
|
dotnet_diagnostic.IDE0064.severity = error
|
||||||
|
|
||||||
|
dotnet_analyzer_diagnostic.severity = error
|
||||||
|
# CS1591: Missing XML comment for publicly visible type or member
|
||||||
|
dotnet_diagnostic.CS1591.severity = suggestion
|
||||||
|
# CA1018: Mark attributes with AttributeUsageAttribute
|
||||||
|
dotnet_diagnostic.CA1018.severity = error
|
||||||
|
# CA1304: Specify CultureInfo
|
||||||
|
dotnet_diagnostic.CA1304.severity = warning
|
||||||
|
# CA1802: Use literals where appropriate
|
||||||
|
dotnet_diagnostic.CA1802.severity = warning
|
||||||
|
# CA1813: Avoid unsealed attributes
|
||||||
|
dotnet_diagnostic.CA1813.severity = error
|
||||||
|
# CA1815: Override equals and operator equals on value types
|
||||||
|
dotnet_diagnostic.CA1815.severity = warning
|
||||||
|
# CA1820: Test for empty strings using string length
|
||||||
|
dotnet_diagnostic.CA1820.severity = warning
|
||||||
|
# CA1821: Remove empty finalizers
|
||||||
|
dotnet_diagnostic.CA1821.severity = warning
|
||||||
|
# CA1822: Mark members as static
|
||||||
|
dotnet_diagnostic.CA1822.severity = suggestion
|
||||||
|
# CA1823: Avoid unused private fields
|
||||||
|
dotnet_diagnostic.CA1823.severity = warning
|
||||||
|
dotnet_code_quality.CA1822.api_surface = private, internal
|
||||||
|
# CA1825: Avoid zero-length array allocations
|
||||||
|
dotnet_diagnostic.CA1825.severity = warning
|
||||||
|
# CA1826: Use property instead of Linq Enumerable method
|
||||||
|
dotnet_diagnostic.CA1826.severity = suggestion
|
||||||
|
# CA1827: Do not use Count/LongCount when Any can be used
|
||||||
|
dotnet_diagnostic.CA1827.severity = warning
|
||||||
|
# CA1828: Do not use CountAsync/LongCountAsync when AnyAsync can be used
|
||||||
|
dotnet_diagnostic.CA1828.severity = warning
|
||||||
|
# CA1829: Use Length/Count property instead of Enumerable.Count method
|
||||||
|
dotnet_diagnostic.CA1829.severity = warning
|
||||||
|
#CA1847: Use string.Contains(char) instead of string.Contains(string) with single characters
|
||||||
|
dotnet_diagnostic.CA1847.severity = warning
|
||||||
|
#CA1854: Prefer the IDictionary.TryGetValue(TKey, out TValue) method
|
||||||
|
dotnet_diagnostic.CA1854.severity = warning
|
||||||
|
#CA2211:Non-constant fields should not be visible
|
||||||
|
dotnet_diagnostic.CA2211.severity = error
|
15
.forgejo/workflows/README.md
Normal file
15
.forgejo/workflows/README.md
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
# workflows
|
||||||
|
|
||||||
|
## default.yml
|
||||||
|
|
||||||
|
The default workflow which is ran on every `push` or `pull_request` which will
|
||||||
|
build and package the project. This will also run the tests as part of the
|
||||||
|
workflow to ensure the code actually behaves like it should.
|
||||||
|
|
||||||
|
When the workflow runs on the `develop` branch of the repository, then an alpha
|
||||||
|
build is released to the registry.
|
||||||
|
|
||||||
|
## release.yml
|
||||||
|
|
||||||
|
The release workflow just build the current repository ref and publishes the
|
||||||
|
artifacts to the registry.
|
37
.forgejo/workflows/default.yml
Normal file
37
.forgejo/workflows/default.yml
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
name: default
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["main", "develop"]
|
||||||
|
paths-ignore:
|
||||||
|
- "doc/**"
|
||||||
|
- "*.md"
|
||||||
|
pull_request:
|
||||||
|
branches: ["main", "develop"]
|
||||||
|
paths-ignore:
|
||||||
|
- "doc/**"
|
||||||
|
- "*.md"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
default:
|
||||||
|
runs-on: debian-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
dotnet-version: ["8.0"]
|
||||||
|
container: mcr.microsoft.com/dotnet/sdk:${{ matrix.dotnet-version }}
|
||||||
|
steps:
|
||||||
|
- name: checkout
|
||||||
|
uses: https://git.geekeey.de/actions/checkout@1
|
||||||
|
|
||||||
|
- name: nuget login
|
||||||
|
run: |
|
||||||
|
# This token is readonly and can only be used for restore
|
||||||
|
dotnet nuget update source geekeey --store-password-in-clear-text \
|
||||||
|
--username "${{ github.actor }}" --password "${{ github.token }}"
|
||||||
|
|
||||||
|
- name: dotnet pack
|
||||||
|
run: |
|
||||||
|
dotnet pack -p:ContinuousIntegrationBuild=true
|
||||||
|
|
||||||
|
- name: dotnet test
|
||||||
|
run: |
|
||||||
|
dotnet test
|
24
.forgejo/workflows/release.yml
Normal file
24
.forgejo/workflows/release.yml
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
name: release
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["[0-9]+.[0-9]+.[0-9]+"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: debian-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
dotnet-version: ["8.0"]
|
||||||
|
container: mcr.microsoft.com/dotnet/sdk:${{ matrix.dotnet-version }}
|
||||||
|
steps:
|
||||||
|
- uses: https://git.geekeey.de/actions/checkout@1
|
||||||
|
|
||||||
|
- name: nuget login
|
||||||
|
run: |
|
||||||
|
# This token is readonly and can only be used for restore
|
||||||
|
dotnet nuget update source geekeey --store-password-in-clear-text \
|
||||||
|
--username "${{ github.actor }}" --password "${{ github.token }}"
|
||||||
|
|
||||||
|
- name: dotnet pack
|
||||||
|
run: |
|
||||||
|
dotnet pack -p:ContinuousIntegrationBuild=true
|
478
.gitignore
vendored
Normal file
478
.gitignore
vendored
Normal file
|
@ -0,0 +1,478 @@
|
||||||
|
## Ignore Visual Studio temporary files, build results, and
|
||||||
|
## files generated by popular Visual Studio add-ons.
|
||||||
|
##
|
||||||
|
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
|
||||||
|
|
||||||
|
# User-specific files
|
||||||
|
*.rsuser
|
||||||
|
*.suo
|
||||||
|
*.user
|
||||||
|
*.userosscache
|
||||||
|
*.sln.docstates
|
||||||
|
|
||||||
|
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||||
|
*.userprefs
|
||||||
|
|
||||||
|
# Mono auto generated files
|
||||||
|
mono_crash.*
|
||||||
|
|
||||||
|
# Build results
|
||||||
|
[Dd]ebug/
|
||||||
|
[Dd]ebugPublic/
|
||||||
|
[Rr]elease/
|
||||||
|
[Rr]eleases/
|
||||||
|
x64/
|
||||||
|
x86/
|
||||||
|
[Ww][Ii][Nn]32/
|
||||||
|
[Aa][Rr][Mm]/
|
||||||
|
[Aa][Rr][Mm]64/
|
||||||
|
bld/
|
||||||
|
[Bb]in/
|
||||||
|
[Oo]bj/
|
||||||
|
[Ll]og/
|
||||||
|
[Ll]ogs/
|
||||||
|
|
||||||
|
# Visual Studio 2015/2017 cache/options directory
|
||||||
|
.vs/
|
||||||
|
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||||
|
#wwwroot/
|
||||||
|
|
||||||
|
# Visual Studio 2017 auto generated files
|
||||||
|
Generated\ Files/
|
||||||
|
|
||||||
|
# MSTest test Results
|
||||||
|
[Tt]est[Rr]esult*/
|
||||||
|
[Bb]uild[Ll]og.*
|
||||||
|
|
||||||
|
# NUnit
|
||||||
|
*.VisualState.xml
|
||||||
|
TestResult.xml
|
||||||
|
nunit-*.xml
|
||||||
|
|
||||||
|
# Build Results of an ATL Project
|
||||||
|
[Dd]ebugPS/
|
||||||
|
[Rr]eleasePS/
|
||||||
|
dlldata.c
|
||||||
|
|
||||||
|
# Benchmark Results
|
||||||
|
BenchmarkDotNet.Artifacts/
|
||||||
|
|
||||||
|
# .NET
|
||||||
|
project.lock.json
|
||||||
|
project.fragment.lock.json
|
||||||
|
artifacts/
|
||||||
|
|
||||||
|
# Tye
|
||||||
|
.tye/
|
||||||
|
|
||||||
|
# ASP.NET Scaffolding
|
||||||
|
ScaffoldingReadMe.txt
|
||||||
|
|
||||||
|
# StyleCop
|
||||||
|
StyleCopReport.xml
|
||||||
|
|
||||||
|
# Files built by Visual Studio
|
||||||
|
*_i.c
|
||||||
|
*_p.c
|
||||||
|
*_h.h
|
||||||
|
*.ilk
|
||||||
|
*.meta
|
||||||
|
*.obj
|
||||||
|
*.iobj
|
||||||
|
*.pch
|
||||||
|
*.pdb
|
||||||
|
*.ipdb
|
||||||
|
*.pgc
|
||||||
|
*.pgd
|
||||||
|
*.rsp
|
||||||
|
*.sbr
|
||||||
|
*.tlb
|
||||||
|
*.tli
|
||||||
|
*.tlh
|
||||||
|
*.tmp
|
||||||
|
*.tmp_proj
|
||||||
|
*_wpftmp.csproj
|
||||||
|
*.log
|
||||||
|
*.tlog
|
||||||
|
*.vspscc
|
||||||
|
*.vssscc
|
||||||
|
.builds
|
||||||
|
*.pidb
|
||||||
|
*.svclog
|
||||||
|
*.scc
|
||||||
|
|
||||||
|
# Chutzpah Test files
|
||||||
|
_Chutzpah*
|
||||||
|
|
||||||
|
# Visual C++ cache files
|
||||||
|
ipch/
|
||||||
|
*.aps
|
||||||
|
*.ncb
|
||||||
|
*.opendb
|
||||||
|
*.opensdf
|
||||||
|
*.sdf
|
||||||
|
*.cachefile
|
||||||
|
*.VC.db
|
||||||
|
*.VC.VC.opendb
|
||||||
|
|
||||||
|
# Visual Studio profiler
|
||||||
|
*.psess
|
||||||
|
*.vsp
|
||||||
|
*.vspx
|
||||||
|
*.sap
|
||||||
|
|
||||||
|
# Visual Studio Trace Files
|
||||||
|
*.e2e
|
||||||
|
|
||||||
|
# TFS 2012 Local Workspace
|
||||||
|
$tf/
|
||||||
|
|
||||||
|
# Guidance Automation Toolkit
|
||||||
|
*.gpState
|
||||||
|
|
||||||
|
# ReSharper is a .NET coding add-in
|
||||||
|
_ReSharper*/
|
||||||
|
*.[Rr]e[Ss]harper
|
||||||
|
*.DotSettings.user
|
||||||
|
|
||||||
|
# TeamCity is a build add-in
|
||||||
|
_TeamCity*
|
||||||
|
|
||||||
|
# DotCover is a Code Coverage Tool
|
||||||
|
*.dotCover
|
||||||
|
|
||||||
|
# AxoCover is a Code Coverage Tool
|
||||||
|
.axoCover/*
|
||||||
|
!.axoCover/settings.json
|
||||||
|
|
||||||
|
# Coverlet is a free, cross platform Code Coverage Tool
|
||||||
|
coverage*.json
|
||||||
|
coverage*.xml
|
||||||
|
coverage*.info
|
||||||
|
|
||||||
|
# Visual Studio code coverage results
|
||||||
|
*.coverage
|
||||||
|
*.coveragexml
|
||||||
|
|
||||||
|
# NCrunch
|
||||||
|
_NCrunch_*
|
||||||
|
.*crunch*.local.xml
|
||||||
|
nCrunchTemp_*
|
||||||
|
|
||||||
|
# MightyMoose
|
||||||
|
*.mm.*
|
||||||
|
AutoTest.Net/
|
||||||
|
|
||||||
|
# Web workbench (sass)
|
||||||
|
.sass-cache/
|
||||||
|
|
||||||
|
# Installshield output folder
|
||||||
|
[Ee]xpress/
|
||||||
|
|
||||||
|
# DocProject is a documentation generator add-in
|
||||||
|
DocProject/buildhelp/
|
||||||
|
DocProject/Help/*.HxT
|
||||||
|
DocProject/Help/*.HxC
|
||||||
|
DocProject/Help/*.hhc
|
||||||
|
DocProject/Help/*.hhk
|
||||||
|
DocProject/Help/*.hhp
|
||||||
|
DocProject/Help/Html2
|
||||||
|
DocProject/Help/html
|
||||||
|
|
||||||
|
# Click-Once directory
|
||||||
|
publish/
|
||||||
|
|
||||||
|
# Publish Web Output
|
||||||
|
*.[Pp]ublish.xml
|
||||||
|
*.azurePubxml
|
||||||
|
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||||
|
# but database connection strings (with potential passwords) will be unencrypted
|
||||||
|
*.pubxml
|
||||||
|
*.publishproj
|
||||||
|
|
||||||
|
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||||
|
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||||
|
# in these scripts will be unencrypted
|
||||||
|
PublishScripts/
|
||||||
|
|
||||||
|
# NuGet Packages
|
||||||
|
*.nupkg
|
||||||
|
# NuGet Symbol Packages
|
||||||
|
*.snupkg
|
||||||
|
# The packages folder can be ignored because of Package Restore
|
||||||
|
**/[Pp]ackages/*
|
||||||
|
!**/src/packages/*
|
||||||
|
# except build/, which is used as an MSBuild target.
|
||||||
|
!**/[Pp]ackages/build/
|
||||||
|
# Uncomment if necessary however generally it will be regenerated when needed
|
||||||
|
#!**/[Pp]ackages/repositories.config
|
||||||
|
# NuGet v3's project.json files produces more ignorable files
|
||||||
|
*.nuget.props
|
||||||
|
*.nuget.targets
|
||||||
|
|
||||||
|
# Microsoft Azure Build Output
|
||||||
|
csx/
|
||||||
|
*.build.csdef
|
||||||
|
|
||||||
|
# Microsoft Azure Emulator
|
||||||
|
ecf/
|
||||||
|
rcf/
|
||||||
|
|
||||||
|
# Windows Store app package directories and files
|
||||||
|
AppPackages/
|
||||||
|
BundleArtifacts/
|
||||||
|
Package.StoreAssociation.xml
|
||||||
|
_pkginfo.txt
|
||||||
|
*.appx
|
||||||
|
*.appxbundle
|
||||||
|
*.appxupload
|
||||||
|
|
||||||
|
# Visual Studio cache files
|
||||||
|
# files ending in .cache can be ignored
|
||||||
|
*.[Cc]ache
|
||||||
|
# but keep track of directories ending in .cache
|
||||||
|
!?*.[Cc]ache/
|
||||||
|
|
||||||
|
# Others
|
||||||
|
ClientBin/
|
||||||
|
~$*
|
||||||
|
*~
|
||||||
|
*.dbmdl
|
||||||
|
*.dbproj.schemaview
|
||||||
|
*.jfm
|
||||||
|
*.pfx
|
||||||
|
*.publishsettings
|
||||||
|
orleans.codegen.cs
|
||||||
|
|
||||||
|
# Including strong name files can present a security risk
|
||||||
|
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
||||||
|
#*.snk
|
||||||
|
|
||||||
|
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
||||||
|
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||||
|
#bower_components/
|
||||||
|
|
||||||
|
# RIA/Silverlight projects
|
||||||
|
Generated_Code/
|
||||||
|
|
||||||
|
# Backup & report files from converting an old project file
|
||||||
|
# to a newer Visual Studio version. Backup files are not needed,
|
||||||
|
# because we have git ;-)
|
||||||
|
_UpgradeReport_Files/
|
||||||
|
Backup*/
|
||||||
|
UpgradeLog*.XML
|
||||||
|
UpgradeLog*.htm
|
||||||
|
ServiceFabricBackup/
|
||||||
|
*.rptproj.bak
|
||||||
|
|
||||||
|
# SQL Server files
|
||||||
|
*.mdf
|
||||||
|
*.ldf
|
||||||
|
*.ndf
|
||||||
|
|
||||||
|
# Business Intelligence projects
|
||||||
|
*.rdl.data
|
||||||
|
*.bim.layout
|
||||||
|
*.bim_*.settings
|
||||||
|
*.rptproj.rsuser
|
||||||
|
*- [Bb]ackup.rdl
|
||||||
|
*- [Bb]ackup ([0-9]).rdl
|
||||||
|
*- [Bb]ackup ([0-9][0-9]).rdl
|
||||||
|
|
||||||
|
# Microsoft Fakes
|
||||||
|
FakesAssemblies/
|
||||||
|
|
||||||
|
# GhostDoc plugin setting file
|
||||||
|
*.GhostDoc.xml
|
||||||
|
|
||||||
|
# Node.js Tools for Visual Studio
|
||||||
|
.ntvs_analysis.dat
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Visual Studio 6 build log
|
||||||
|
*.plg
|
||||||
|
|
||||||
|
# Visual Studio 6 workspace options file
|
||||||
|
*.opt
|
||||||
|
|
||||||
|
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||||
|
*.vbw
|
||||||
|
|
||||||
|
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
|
||||||
|
*.vbp
|
||||||
|
|
||||||
|
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
|
||||||
|
*.dsw
|
||||||
|
*.dsp
|
||||||
|
|
||||||
|
# Visual Studio 6 technical files
|
||||||
|
*.ncb
|
||||||
|
*.aps
|
||||||
|
|
||||||
|
# Visual Studio LightSwitch build output
|
||||||
|
**/*.HTMLClient/GeneratedArtifacts
|
||||||
|
**/*.DesktopClient/GeneratedArtifacts
|
||||||
|
**/*.DesktopClient/ModelManifest.xml
|
||||||
|
**/*.Server/GeneratedArtifacts
|
||||||
|
**/*.Server/ModelManifest.xml
|
||||||
|
_Pvt_Extensions
|
||||||
|
|
||||||
|
# Paket dependency manager
|
||||||
|
.paket/paket.exe
|
||||||
|
paket-files/
|
||||||
|
|
||||||
|
# FAKE - F# Make
|
||||||
|
.fake/
|
||||||
|
|
||||||
|
# CodeRush personal settings
|
||||||
|
.cr/personal
|
||||||
|
|
||||||
|
# Python Tools for Visual Studio (PTVS)
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# Cake - Uncomment if you are using it
|
||||||
|
# tools/**
|
||||||
|
# !tools/packages.config
|
||||||
|
|
||||||
|
# Tabs Studio
|
||||||
|
*.tss
|
||||||
|
|
||||||
|
# Telerik's JustMock configuration file
|
||||||
|
*.jmconfig
|
||||||
|
|
||||||
|
# BizTalk build output
|
||||||
|
*.btp.cs
|
||||||
|
*.btm.cs
|
||||||
|
*.odx.cs
|
||||||
|
*.xsd.cs
|
||||||
|
|
||||||
|
# OpenCover UI analysis results
|
||||||
|
OpenCover/
|
||||||
|
|
||||||
|
# Azure Stream Analytics local run output
|
||||||
|
ASALocalRun/
|
||||||
|
|
||||||
|
# MSBuild Binary and Structured Log
|
||||||
|
*.binlog
|
||||||
|
|
||||||
|
# NVidia Nsight GPU debugger configuration file
|
||||||
|
*.nvuser
|
||||||
|
|
||||||
|
# MFractors (Xamarin productivity tool) working folder
|
||||||
|
.mfractor/
|
||||||
|
|
||||||
|
# Local History for Visual Studio
|
||||||
|
.localhistory/
|
||||||
|
|
||||||
|
# Visual Studio History (VSHistory) files
|
||||||
|
.vshistory/
|
||||||
|
|
||||||
|
# BeatPulse healthcheck temp database
|
||||||
|
healthchecksdb
|
||||||
|
|
||||||
|
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
||||||
|
MigrationBackup/
|
||||||
|
|
||||||
|
# Ionide (cross platform F# VS Code tools) working folder
|
||||||
|
.ionide/
|
||||||
|
|
||||||
|
# Fody - auto-generated XML schema
|
||||||
|
FodyWeavers.xsd
|
||||||
|
|
||||||
|
# VS Code files for those working on multiple tools
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
*.code-workspace
|
||||||
|
|
||||||
|
# Local History for Visual Studio Code
|
||||||
|
.history/
|
||||||
|
|
||||||
|
# Windows Installer files from build outputs
|
||||||
|
*.cab
|
||||||
|
*.msi
|
||||||
|
*.msix
|
||||||
|
*.msm
|
||||||
|
*.msp
|
||||||
|
|
||||||
|
# JetBrains Rider
|
||||||
|
*.sln.iml
|
||||||
|
|
||||||
|
##
|
||||||
|
## Visual studio for Mac
|
||||||
|
##
|
||||||
|
|
||||||
|
|
||||||
|
# globs
|
||||||
|
Makefile.in
|
||||||
|
*.userprefs
|
||||||
|
*.usertasks
|
||||||
|
config.make
|
||||||
|
config.status
|
||||||
|
aclocal.m4
|
||||||
|
install-sh
|
||||||
|
autom4te.cache/
|
||||||
|
*.tar.gz
|
||||||
|
tarballs/
|
||||||
|
test-results/
|
||||||
|
|
||||||
|
# Mac bundle stuff
|
||||||
|
*.dmg
|
||||||
|
*.app
|
||||||
|
|
||||||
|
# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore
|
||||||
|
# General
|
||||||
|
.DS_Store
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
|
||||||
|
# Icon must end with two \r
|
||||||
|
Icon
|
||||||
|
|
||||||
|
|
||||||
|
# Thumbnails
|
||||||
|
._*
|
||||||
|
|
||||||
|
# Files that might appear in the root of a volume
|
||||||
|
.DocumentRevisions-V100
|
||||||
|
.fseventsd
|
||||||
|
.Spotlight-V100
|
||||||
|
.TemporaryItems
|
||||||
|
.Trashes
|
||||||
|
.VolumeIcon.icns
|
||||||
|
.com.apple.timemachine.donotpresent
|
||||||
|
|
||||||
|
# Directories potentially created on remote AFP share
|
||||||
|
.AppleDB
|
||||||
|
.AppleDesktop
|
||||||
|
Network Trash Folder
|
||||||
|
Temporary Items
|
||||||
|
.apdisk
|
||||||
|
|
||||||
|
# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore
|
||||||
|
# Windows thumbnail cache files
|
||||||
|
Thumbs.db
|
||||||
|
ehthumbs.db
|
||||||
|
ehthumbs_vista.db
|
||||||
|
|
||||||
|
# Dump file
|
||||||
|
*.stackdump
|
||||||
|
|
||||||
|
# Folder config file
|
||||||
|
[Dd]esktop.ini
|
||||||
|
|
||||||
|
# Recycle Bin used on file shares
|
||||||
|
$RECYCLE.BIN/
|
||||||
|
|
||||||
|
# Windows Installer files
|
||||||
|
*.cab
|
||||||
|
*.msi
|
||||||
|
*.msix
|
||||||
|
*.msm
|
||||||
|
*.msp
|
||||||
|
|
||||||
|
# Windows shortcuts
|
||||||
|
*.lnk
|
27
Directory.Build.props
Normal file
27
Directory.Build.props
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<UseArtifactsOutput>true</UseArtifactsOutput>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
|
||||||
|
<VersionPrefix>1.0.0</VersionPrefix>
|
||||||
|
<!--<VersionSuffix></VersionSuffix>-->
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<AssemblyName>Geekeey.Extensions.$(MSBuildProjectName)</AssemblyName>
|
||||||
|
<RootNamespace>Geekeey.Extensions.$(MSBuildProjectName)</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<Authors>The Geekeey Team</Authors>
|
||||||
|
<Description>A simple and lightweight way to interact with other processes from C#.</Description>
|
||||||
|
<PackageTags>geekeey utility process</PackageTags>
|
||||||
|
<PackageReadmeFile>package-readme.md</PackageReadmeFile>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
3
Directory.Build.targets
Normal file
3
Directory.Build.targets
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<Project>
|
||||||
|
</Project>
|
15
Directory.Packages.props
Normal file
15
Directory.Packages.props
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.7.1" />
|
||||||
|
<PackageVersion Include="NUnit" Version="3.14.0" />
|
||||||
|
<PackageVersion Include="NUnit3TestAdapter" Version="4.5.0" />
|
||||||
|
<PackageVersion Include="NUnit.Analyzers" Version="4.1.0" />
|
||||||
|
<PackageVersion Include="coverlet.collector" Version="6.0.1" />
|
||||||
|
<PackageVersion Include="Spectre.Console" Version="0.49.1" />
|
||||||
|
<PackageVersion Include="Spectre.Console.Cli" Version="0.49.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
36
Geekeey.Extensions.Process.sln
Normal file
36
Geekeey.Extensions.Process.sln
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.0.31903.59
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Process", "src\Process\Process.csproj", "{0B246E7A-565E-45E7-84C2-37A43C0982A3}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Process.Tests", "src\Process.Tests\Process.Tests.csproj", "{C0919570-F420-49F5-A8B4-B5DF16A8EC05}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Process.Tests.Dummy", "src\Process.Tests.Dummy\Process.Tests.Dummy.csproj", "{8E6F465E-3FEE-4789-A750-9FED80CCCB8E}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{0B246E7A-565E-45E7-84C2-37A43C0982A3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{0B246E7A-565E-45E7-84C2-37A43C0982A3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{0B246E7A-565E-45E7-84C2-37A43C0982A3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{0B246E7A-565E-45E7-84C2-37A43C0982A3}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{C0919570-F420-49F5-A8B4-B5DF16A8EC05}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{C0919570-F420-49F5-A8B4-B5DF16A8EC05}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{C0919570-F420-49F5-A8B4-B5DF16A8EC05}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{C0919570-F420-49F5-A8B4-B5DF16A8EC05}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{8E6F465E-3FEE-4789-A750-9FED80CCCB8E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{8E6F465E-3FEE-4789-A750-9FED80CCCB8E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{8E6F465E-3FEE-4789-A750-9FED80CCCB8E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{8E6F465E-3FEE-4789-A750-9FED80CCCB8E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(NestedProjects) = preSolution
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
9
global.json
Normal file
9
global.json
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"sdk": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"rollForward": "latestMajor",
|
||||||
|
"allowPrerelease": true
|
||||||
|
},
|
||||||
|
"msbuild-sdks": {
|
||||||
|
}
|
||||||
|
}
|
19
nuget.config
Normal file
19
nuget.config
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<configuration>
|
||||||
|
<config>
|
||||||
|
<add key="defaultPushSource" value="geekeey" />
|
||||||
|
</config>
|
||||||
|
<packageSources>
|
||||||
|
<clear />
|
||||||
|
<add key="nuget" value="https://api.nuget.org/v3/index.json" />
|
||||||
|
<add key="geekeey" value="https://git.geekeey.de/api/packages/geekeey/nuget/index.json" />
|
||||||
|
</packageSources>
|
||||||
|
<packageSourceMapping>
|
||||||
|
<packageSource key="nuget">
|
||||||
|
<package pattern="*" />
|
||||||
|
</packageSource>
|
||||||
|
<packageSource key="geekeey">
|
||||||
|
<package pattern="Geekeey.*" />
|
||||||
|
</packageSource>
|
||||||
|
</packageSourceMapping>
|
||||||
|
</configuration>
|
25
src/Process.Tests.Dummy/Commands/EchoCommand.cs
Normal file
25
src/Process.Tests.Dummy/Commands/EchoCommand.cs
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
using Spectre.Console.Cli;
|
||||||
|
|
||||||
|
namespace Geekeey.Extensions.Process.Tests.Dummy.Commands;
|
||||||
|
|
||||||
|
internal sealed class EchoCommand : Command<EchoCommand.Settings>
|
||||||
|
{
|
||||||
|
public sealed class Settings : CommandSettings
|
||||||
|
{
|
||||||
|
[CommandOption("--target")] public OutputTarget Target { get; init; } = OutputTarget.StdOut;
|
||||||
|
|
||||||
|
[CommandOption("--separator <sep>")] public string Separator { get; init; } = " ";
|
||||||
|
[CommandArgument(0, "[line]")] public string[] Items { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public override int Execute(CommandContext context, Settings settings)
|
||||||
|
{
|
||||||
|
foreach (var writer in settings.Target.GetWriters())
|
||||||
|
{
|
||||||
|
writer.WriteLine(string.Join(settings.Separator, settings.Items));
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
39
src/Process.Tests.Dummy/Commands/EchoStdinCommand.cs
Normal file
39
src/Process.Tests.Dummy/Commands/EchoStdinCommand.cs
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
using System.Buffers;
|
||||||
|
|
||||||
|
using Spectre.Console.Cli;
|
||||||
|
|
||||||
|
namespace Geekeey.Extensions.Process.Tests.Dummy.Commands;
|
||||||
|
|
||||||
|
internal sealed class EchoStdinCommand : Command<EchoStdinCommand.Settings>
|
||||||
|
{
|
||||||
|
public sealed class Settings : CommandSettings
|
||||||
|
{
|
||||||
|
[CommandOption("--target")] public OutputTarget Target { get; init; } = OutputTarget.StdOut;
|
||||||
|
|
||||||
|
[CommandOption("--length")] public long Length { get; init; } = long.MaxValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override int Execute(CommandContext context, Settings settings)
|
||||||
|
{
|
||||||
|
using var buffer = MemoryPool<char>.Shared.Rent(81920);
|
||||||
|
|
||||||
|
var totalBytesRead = 0L;
|
||||||
|
while (totalBytesRead < settings.Length)
|
||||||
|
{
|
||||||
|
var bytesWanted = (int)Math.Min(buffer.Memory.Length, settings.Length - totalBytesRead);
|
||||||
|
|
||||||
|
var bytesRead = Console.In.Read(buffer.Memory.Span[..bytesWanted]);
|
||||||
|
if (bytesRead <= 0)
|
||||||
|
break;
|
||||||
|
|
||||||
|
foreach (var writer in settings.Target.GetWriters())
|
||||||
|
{
|
||||||
|
writer.Write(buffer.Memory.Span[..bytesRead]);
|
||||||
|
}
|
||||||
|
|
||||||
|
totalBytesRead += bytesRead;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
21
src/Process.Tests.Dummy/Commands/EnvironmentCommand.cs
Normal file
21
src/Process.Tests.Dummy/Commands/EnvironmentCommand.cs
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
using Spectre.Console.Cli;
|
||||||
|
|
||||||
|
namespace Geekeey.Extensions.Process.Tests.Dummy.Commands;
|
||||||
|
|
||||||
|
internal sealed class EnvironmentCommand : Command<EnvironmentCommand.Settings>
|
||||||
|
{
|
||||||
|
public sealed class Settings : CommandSettings
|
||||||
|
{
|
||||||
|
[CommandArgument(0, "<ARGUMENT>")] public string[] Variables { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public override int Execute(CommandContext context, Settings settings)
|
||||||
|
{
|
||||||
|
foreach (var name in settings.Variables)
|
||||||
|
{
|
||||||
|
Console.Out.WriteLine(Environment.GetEnvironmentVariable(name) ?? string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
17
src/Process.Tests.Dummy/Commands/ExitCommand.cs
Normal file
17
src/Process.Tests.Dummy/Commands/ExitCommand.cs
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
using Spectre.Console.Cli;
|
||||||
|
|
||||||
|
namespace Geekeey.Extensions.Process.Tests.Dummy.Commands;
|
||||||
|
|
||||||
|
internal sealed class ExitCommand : Command<ExitCommand.Settings>
|
||||||
|
{
|
||||||
|
public sealed class Settings : CommandSettings
|
||||||
|
{
|
||||||
|
[CommandArgument(1, "<code>")] public int Code { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public override int Execute(CommandContext context, Settings settings)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"Exit code set to {settings.Code}");
|
||||||
|
return settings.Code;
|
||||||
|
}
|
||||||
|
}
|
16
src/Process.Tests.Dummy/Commands/WorkingDirectoryCommand.cs
Normal file
16
src/Process.Tests.Dummy/Commands/WorkingDirectoryCommand.cs
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
using Spectre.Console.Cli;
|
||||||
|
|
||||||
|
namespace Geekeey.Extensions.Process.Tests.Dummy.Commands;
|
||||||
|
|
||||||
|
internal sealed class WorkingDirectoryCommand : Command<WorkingDirectoryCommand.Settings>
|
||||||
|
{
|
||||||
|
public sealed class Settings : CommandSettings
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public override int Execute(CommandContext context, Settings settings)
|
||||||
|
{
|
||||||
|
Console.Out.WriteLine(Directory.GetCurrentDirectory());
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
21
src/Process.Tests.Dummy/OutputTarget.cs
Normal file
21
src/Process.Tests.Dummy/OutputTarget.cs
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
namespace Geekeey.Extensions.Process.Tests.Dummy;
|
||||||
|
|
||||||
|
[Flags]
|
||||||
|
public enum OutputTarget
|
||||||
|
{
|
||||||
|
StdOut = 1,
|
||||||
|
StdErr = 2,
|
||||||
|
All = StdOut | StdErr
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class OutputTargetExtensions
|
||||||
|
{
|
||||||
|
public static IEnumerable<TextWriter> GetWriters(this OutputTarget target)
|
||||||
|
{
|
||||||
|
if (target.HasFlag(OutputTarget.StdOut))
|
||||||
|
yield return Console.Out;
|
||||||
|
|
||||||
|
if (target.HasFlag(OutputTarget.StdErr))
|
||||||
|
yield return Console.Error;
|
||||||
|
}
|
||||||
|
}
|
11
src/Process.Tests.Dummy/Process.Tests.Dummy.csproj
Normal file
11
src/Process.Tests.Dummy/Process.Tests.Dummy.csproj
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Spectre.Console" PrivateAssets="compile" />
|
||||||
|
<PackageReference Include="Spectre.Console.Cli" PrivateAssets="compile" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
36
src/Process.Tests.Dummy/Program.cs
Normal file
36
src/Process.Tests.Dummy/Program.cs
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
using Geekeey.Extensions.Process.Tests.Dummy.Commands;
|
||||||
|
|
||||||
|
using Spectre.Console.Cli;
|
||||||
|
|
||||||
|
namespace Geekeey.Extensions.Process.Tests.Dummy;
|
||||||
|
|
||||||
|
public static class Program
|
||||||
|
{
|
||||||
|
private static readonly string? FileExtension = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "exe" : null;
|
||||||
|
|
||||||
|
#pragma warning disable IL3000 // only for testing where we don't run in single files!
|
||||||
|
private static readonly string AssemblyPath = Assembly.GetExecutingAssembly().Location;
|
||||||
|
#pragma warning restore IL3000
|
||||||
|
|
||||||
|
public static string FilePath { get; } = Path.ChangeExtension(AssemblyPath, FileExtension);
|
||||||
|
|
||||||
|
private static Task<int> Main(string[] args)
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable("DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION", "false");
|
||||||
|
var app = new CommandApp();
|
||||||
|
app.Configure(Configuration);
|
||||||
|
return app.RunAsync(args);
|
||||||
|
|
||||||
|
void Configuration(IConfigurator configuration)
|
||||||
|
{
|
||||||
|
configuration.AddCommand<EchoCommand>("echo");
|
||||||
|
configuration.AddCommand<EchoStdinCommand>("echo-stdin");
|
||||||
|
configuration.AddCommand<EnvironmentCommand>("env");
|
||||||
|
configuration.AddCommand<WorkingDirectoryCommand>("cwd");
|
||||||
|
configuration.AddCommand<ExitCommand>("exit");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
68
src/Process.Tests/BufferedExecuteTests.cs
Normal file
68
src/Process.Tests/BufferedExecuteTests.cs
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
using Geekeey.Extensions.Process.Buffered;
|
||||||
|
using Geekeey.Extensions.Process.Tests.Dummy;
|
||||||
|
|
||||||
|
namespace Geekeey.Extensions.Process.Tests;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
internal sealed class BufferedExecuteTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public async Task ExecuteBuffered_Stdout()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var cmd = new Command(Program.FilePath)
|
||||||
|
.WithArguments(["echo", "--target", "stdout", "Hello stdout",]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await cmd.ExecuteBufferedAsync();
|
||||||
|
|
||||||
|
Assume.That(result.ExitCode, Is.EqualTo(0));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(result.StandardOutput.Trim(), Is.EqualTo("Hello stdout"));
|
||||||
|
Assert.That(result.StandardError.Trim(), Is.Empty);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ExecuteBuffered_Stderr()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var cmd = new Command(Program.FilePath)
|
||||||
|
.WithArguments(["echo", "--target", "stderr", "Hello stdout",]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await cmd.ExecuteBufferedAsync();
|
||||||
|
|
||||||
|
Assume.That(result.ExitCode, Is.EqualTo(0));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(result.StandardOutput.Trim(), Is.Empty);
|
||||||
|
Assert.That(result.StandardError.Trim(), Is.EqualTo("Hello stdout"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ExecuteBuffered_Stdout_And_Stderr()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var cmd = new Command(Program.FilePath)
|
||||||
|
.WithArguments(["echo", "--target", "all", "Hello stdout and stderr",]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await cmd.ExecuteBufferedAsync();
|
||||||
|
|
||||||
|
Assume.That(result.ExitCode, Is.EqualTo(0));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(result.StandardOutput.Trim(), Is.EqualTo("Hello stdout and stderr"));
|
||||||
|
Assert.That(result.StandardError.Trim(), Is.EqualTo("Hello stdout and stderr"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
271
src/Process.Tests/CommandTests.cs
Normal file
271
src/Process.Tests/CommandTests.cs
Normal file
|
@ -0,0 +1,271 @@
|
||||||
|
namespace Geekeey.Extensions.Process.Tests;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
internal sealed class CommandTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public void New_Command_Defaults()
|
||||||
|
{
|
||||||
|
var cmd = new Command("foo");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(cmd.TargetFilePath, Is.EqualTo("foo"));
|
||||||
|
Assert.That(cmd.Arguments, Is.Empty);
|
||||||
|
Assert.That(cmd.WorkingDirPath, Is.EqualTo(Directory.GetCurrentDirectory()));
|
||||||
|
Assert.That(cmd.EnvironmentVariables, Is.Empty);
|
||||||
|
Assert.That(cmd.Validation, Is.EqualTo(CommandExitBehaviour.ZeroExitCode));
|
||||||
|
Assert.That(cmd.StandardInputPipe, Is.EqualTo(PipeSource.Null));
|
||||||
|
Assert.That(cmd.StandardOutputPipe, Is.EqualTo(PipeTarget.Null));
|
||||||
|
Assert.That(cmd.StandardErrorPipe, Is.EqualTo(PipeTarget.Null));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void New_Command_WithTargetFile()
|
||||||
|
{
|
||||||
|
var cmd = new Command("foo");
|
||||||
|
var modified = cmd.WithTargetFile("bar");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(modified.TargetFilePath, Is.EqualTo("bar"));
|
||||||
|
Assert.That(modified.Arguments, Is.EqualTo(cmd.Arguments));
|
||||||
|
Assert.That(modified.WorkingDirPath, Is.EqualTo(cmd.WorkingDirPath));
|
||||||
|
Assert.That(modified.EnvironmentVariables, Is.EqualTo(cmd.EnvironmentVariables));
|
||||||
|
Assert.That(modified.Validation, Is.EqualTo(cmd.Validation));
|
||||||
|
Assert.That(modified.StandardInputPipe, Is.EqualTo(cmd.StandardInputPipe));
|
||||||
|
Assert.That(modified.StandardOutputPipe, Is.EqualTo(cmd.StandardOutputPipe));
|
||||||
|
Assert.That(modified.StandardErrorPipe, Is.EqualTo(cmd.StandardErrorPipe));
|
||||||
|
|
||||||
|
Assert.That(cmd.TargetFilePath, Is.Not.EqualTo("bar"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void New_Command_WithArguments()
|
||||||
|
{
|
||||||
|
var cmd = new Command("foo").WithArguments("xxx");
|
||||||
|
var modified = cmd.WithArguments("abc def");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(modified.TargetFilePath, Is.EqualTo(cmd.TargetFilePath));
|
||||||
|
Assert.That(modified.Arguments, Is.EqualTo("abc def"));
|
||||||
|
Assert.That(modified.WorkingDirPath, Is.EqualTo(cmd.WorkingDirPath));
|
||||||
|
Assert.That(modified.EnvironmentVariables, Is.EqualTo(cmd.EnvironmentVariables));
|
||||||
|
Assert.That(modified.Validation, Is.EqualTo(cmd.Validation));
|
||||||
|
Assert.That(modified.StandardInputPipe, Is.EqualTo(cmd.StandardInputPipe));
|
||||||
|
Assert.That(modified.StandardOutputPipe, Is.EqualTo(cmd.StandardOutputPipe));
|
||||||
|
Assert.That(modified.StandardErrorPipe, Is.EqualTo(cmd.StandardErrorPipe));
|
||||||
|
|
||||||
|
Assert.That(cmd.Arguments, Is.Not.EqualTo("abc def"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void New_Command_WithArguments_Array()
|
||||||
|
{
|
||||||
|
var cmd = new Command("foo").WithArguments("xxx");
|
||||||
|
var modified = cmd.WithArguments(["abc", "def"]);
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(modified.TargetFilePath, Is.EqualTo(cmd.TargetFilePath));
|
||||||
|
Assert.That(modified.Arguments, Is.EqualTo("abc def"));
|
||||||
|
Assert.That(modified.WorkingDirPath, Is.EqualTo(cmd.WorkingDirPath));
|
||||||
|
Assert.That(modified.EnvironmentVariables, Is.EqualTo(cmd.EnvironmentVariables));
|
||||||
|
Assert.That(modified.Validation, Is.EqualTo(cmd.Validation));
|
||||||
|
Assert.That(modified.StandardInputPipe, Is.EqualTo(cmd.StandardInputPipe));
|
||||||
|
Assert.That(modified.StandardOutputPipe, Is.EqualTo(cmd.StandardOutputPipe));
|
||||||
|
Assert.That(modified.StandardErrorPipe, Is.EqualTo(cmd.StandardErrorPipe));
|
||||||
|
|
||||||
|
Assert.That(cmd.Arguments, Is.Not.EqualTo("abc def"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void New_Command_WithArguments_Builder()
|
||||||
|
{
|
||||||
|
var cmd = new Command("foo").WithArguments("xxx");
|
||||||
|
var modified = cmd.WithArguments(args => args
|
||||||
|
.Add("-a")
|
||||||
|
.Add("foo bar")
|
||||||
|
.Add("\"foo\\\\bar\"")
|
||||||
|
.Add(3.14)
|
||||||
|
.Add(["foo", "bar"])
|
||||||
|
.Add([-10, 12.12]));
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(modified.TargetFilePath, Is.EqualTo(cmd.TargetFilePath));
|
||||||
|
Assert.That(modified.Arguments, Is.EqualTo("-a \"foo bar\" \"\\\"foo\\\\bar\\\"\" 3.14 foo bar -10 12.12"));
|
||||||
|
Assert.That(modified.WorkingDirPath, Is.EqualTo(cmd.WorkingDirPath));
|
||||||
|
Assert.That(modified.EnvironmentVariables, Is.EqualTo(cmd.EnvironmentVariables));
|
||||||
|
Assert.That(modified.Validation, Is.EqualTo(cmd.Validation));
|
||||||
|
Assert.That(modified.StandardInputPipe, Is.EqualTo(cmd.StandardInputPipe));
|
||||||
|
Assert.That(modified.StandardOutputPipe, Is.EqualTo(cmd.StandardOutputPipe));
|
||||||
|
Assert.That(modified.StandardErrorPipe, Is.EqualTo(cmd.StandardErrorPipe));
|
||||||
|
|
||||||
|
Assert.That(cmd.Arguments, Is.Not.EqualTo("-a \"foo bar\" \"\\\"foo\\\\bar\\\"\" 3.14 foo bar -10 12.12"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void New_Command_WithWorkingDirectory()
|
||||||
|
{
|
||||||
|
var cmd = new Command("foo").WithWorkingDirectory("xxx");
|
||||||
|
var modified = cmd.WithWorkingDirectory("new");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(modified.TargetFilePath, Is.EqualTo(cmd.TargetFilePath));
|
||||||
|
Assert.That(modified.Arguments, Is.EqualTo(cmd.Arguments));
|
||||||
|
Assert.That(modified.WorkingDirPath, Is.EqualTo("new"));
|
||||||
|
Assert.That(modified.EnvironmentVariables, Is.EqualTo(cmd.EnvironmentVariables));
|
||||||
|
Assert.That(modified.Validation, Is.EqualTo(cmd.Validation));
|
||||||
|
Assert.That(modified.StandardInputPipe, Is.EqualTo(cmd.StandardInputPipe));
|
||||||
|
Assert.That(modified.StandardOutputPipe, Is.EqualTo(cmd.StandardOutputPipe));
|
||||||
|
Assert.That(modified.StandardErrorPipe, Is.EqualTo(cmd.StandardErrorPipe));
|
||||||
|
|
||||||
|
Assert.That(cmd.WorkingDirPath, Is.Not.EqualTo("new"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void New_Command_WithEnvironmentVariables()
|
||||||
|
{
|
||||||
|
var cmd = new Command("foo").WithEnvironmentVariables(e => e.Set("xxx", "xxx"));
|
||||||
|
var vars = new Dictionary<string, string?> { ["name"] = "value", ["key"] = "door" };
|
||||||
|
var modified = cmd.WithEnvironmentVariables(vars);
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(modified.TargetFilePath, Is.EqualTo(cmd.TargetFilePath));
|
||||||
|
Assert.That(modified.Arguments, Is.EqualTo(cmd.Arguments));
|
||||||
|
Assert.That(modified.WorkingDirPath, Is.EqualTo(cmd.WorkingDirPath));
|
||||||
|
Assert.That(modified.EnvironmentVariables, Is.EqualTo(vars));
|
||||||
|
Assert.That(modified.Validation, Is.EqualTo(cmd.Validation));
|
||||||
|
Assert.That(modified.StandardInputPipe, Is.EqualTo(cmd.StandardInputPipe));
|
||||||
|
Assert.That(modified.StandardOutputPipe, Is.EqualTo(cmd.StandardOutputPipe));
|
||||||
|
Assert.That(modified.StandardErrorPipe, Is.EqualTo(cmd.StandardErrorPipe));
|
||||||
|
|
||||||
|
Assert.That(cmd.EnvironmentVariables, Is.Not.EqualTo(vars));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void New_Command_WithEnvironmentVariables_Builder()
|
||||||
|
{
|
||||||
|
var cmd = new Command("foo").WithEnvironmentVariables(e => e.Set("xxx", "xxx"));
|
||||||
|
var modified = cmd.WithEnvironmentVariables(env => env
|
||||||
|
.Set("name", "value")
|
||||||
|
.Set("key", "door")
|
||||||
|
.Set(new Dictionary<string, string?> { ["zzz"] = "yyy", ["aaa"] = "bbb" }));
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
var vars = new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["name"] = "value", ["key"] = "door", ["zzz"] = "yyy", ["aaa"] = "bbb"
|
||||||
|
};
|
||||||
|
Assert.That(modified.TargetFilePath, Is.EqualTo(cmd.TargetFilePath));
|
||||||
|
Assert.That(modified.Arguments, Is.EqualTo(cmd.Arguments));
|
||||||
|
Assert.That(modified.WorkingDirPath, Is.EqualTo(cmd.WorkingDirPath));
|
||||||
|
Assert.That(modified.EnvironmentVariables, Is.EqualTo(vars));
|
||||||
|
Assert.That(modified.Validation, Is.EqualTo(cmd.Validation));
|
||||||
|
Assert.That(modified.StandardInputPipe, Is.EqualTo(cmd.StandardInputPipe));
|
||||||
|
Assert.That(modified.StandardOutputPipe, Is.EqualTo(cmd.StandardOutputPipe));
|
||||||
|
Assert.That(modified.StandardErrorPipe, Is.EqualTo(cmd.StandardErrorPipe));
|
||||||
|
|
||||||
|
Assert.That(cmd.EnvironmentVariables, Is.Not.EqualTo(vars));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void New_Command_WithValidation()
|
||||||
|
{
|
||||||
|
var cmd = new Command("foo").WithValidation(CommandExitBehaviour.ZeroExitCode);
|
||||||
|
var modified = cmd.WithValidation(CommandExitBehaviour.None);
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(modified.TargetFilePath, Is.EqualTo(cmd.TargetFilePath));
|
||||||
|
Assert.That(modified.Arguments, Is.EqualTo(cmd.Arguments));
|
||||||
|
Assert.That(modified.WorkingDirPath, Is.EqualTo(cmd.WorkingDirPath));
|
||||||
|
Assert.That(modified.EnvironmentVariables, Is.EqualTo(cmd.EnvironmentVariables));
|
||||||
|
Assert.That(modified.Validation, Is.EqualTo(CommandExitBehaviour.None));
|
||||||
|
Assert.That(modified.StandardInputPipe, Is.EqualTo(cmd.StandardInputPipe));
|
||||||
|
Assert.That(modified.StandardOutputPipe, Is.EqualTo(cmd.StandardOutputPipe));
|
||||||
|
Assert.That(modified.StandardErrorPipe, Is.EqualTo(cmd.StandardErrorPipe));
|
||||||
|
|
||||||
|
Assert.That(cmd.Validation, Is.Not.EqualTo(CommandExitBehaviour.None));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void New_Command_WithStandardInputPipe()
|
||||||
|
{
|
||||||
|
var cmd = new Command("foo").WithStandardInputPipe(PipeSource.Null);
|
||||||
|
var pipeSource = PipeSource.FromStream(Stream.Null);
|
||||||
|
var modified = cmd.WithStandardInputPipe(pipeSource);
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(modified.TargetFilePath, Is.EqualTo(cmd.TargetFilePath));
|
||||||
|
Assert.That(modified.Arguments, Is.EqualTo(cmd.Arguments));
|
||||||
|
Assert.That(modified.WorkingDirPath, Is.EqualTo(cmd.WorkingDirPath));
|
||||||
|
Assert.That(modified.EnvironmentVariables, Is.EqualTo(cmd.EnvironmentVariables));
|
||||||
|
Assert.That(modified.Validation, Is.EqualTo(cmd.Validation));
|
||||||
|
Assert.That(modified.StandardInputPipe, Is.EqualTo(pipeSource));
|
||||||
|
Assert.That(modified.StandardOutputPipe, Is.EqualTo(cmd.StandardOutputPipe));
|
||||||
|
Assert.That(modified.StandardErrorPipe, Is.EqualTo(cmd.StandardErrorPipe));
|
||||||
|
|
||||||
|
Assert.That(cmd.StandardInputPipe, Is.Not.EqualTo(pipeSource));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void New_Command_WithStandardOutputPipe()
|
||||||
|
{
|
||||||
|
var cmd = new Command("foo").WithStandardOutputPipe(PipeTarget.Null);
|
||||||
|
var pipeTarget = PipeTarget.ToStream(Stream.Null);
|
||||||
|
var modified = cmd.WithStandardOutputPipe(pipeTarget);
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(modified.TargetFilePath, Is.EqualTo(cmd.TargetFilePath));
|
||||||
|
Assert.That(modified.Arguments, Is.EqualTo(cmd.Arguments));
|
||||||
|
Assert.That(modified.WorkingDirPath, Is.EqualTo(cmd.WorkingDirPath));
|
||||||
|
Assert.That(modified.EnvironmentVariables, Is.EqualTo(cmd.EnvironmentVariables));
|
||||||
|
Assert.That(modified.Validation, Is.EqualTo(cmd.Validation));
|
||||||
|
Assert.That(modified.StandardInputPipe, Is.EqualTo(cmd.StandardInputPipe));
|
||||||
|
Assert.That(modified.StandardOutputPipe, Is.EqualTo(pipeTarget));
|
||||||
|
Assert.That(modified.StandardErrorPipe, Is.EqualTo(cmd.StandardErrorPipe));
|
||||||
|
|
||||||
|
Assert.That(cmd.StandardOutputPipe, Is.Not.EqualTo(pipeTarget));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void New_Command_WithStandardErrorPipe()
|
||||||
|
{
|
||||||
|
var cmd = new Command("foo").WithStandardErrorPipe(PipeTarget.Null);
|
||||||
|
var pipeTarget = PipeTarget.ToStream(Stream.Null);
|
||||||
|
var modified = cmd.WithStandardErrorPipe(pipeTarget);
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(modified.TargetFilePath, Is.EqualTo(cmd.TargetFilePath));
|
||||||
|
Assert.That(modified.Arguments, Is.EqualTo(cmd.Arguments));
|
||||||
|
Assert.That(modified.WorkingDirPath, Is.EqualTo(cmd.WorkingDirPath));
|
||||||
|
Assert.That(modified.EnvironmentVariables, Is.EqualTo(cmd.EnvironmentVariables));
|
||||||
|
Assert.That(modified.Validation, Is.EqualTo(cmd.Validation));
|
||||||
|
Assert.That(modified.StandardInputPipe, Is.EqualTo(cmd.StandardInputPipe));
|
||||||
|
Assert.That(modified.StandardOutputPipe, Is.EqualTo(cmd.StandardOutputPipe));
|
||||||
|
Assert.That(modified.StandardErrorPipe, Is.EqualTo(pipeTarget));
|
||||||
|
|
||||||
|
Assert.That(cmd.StandardErrorPipe, Is.Not.EqualTo(pipeTarget));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
139
src/Process.Tests/ExecuteTests.cs
Normal file
139
src/Process.Tests/ExecuteTests.cs
Normal file
|
@ -0,0 +1,139 @@
|
||||||
|
using System.ComponentModel;
|
||||||
|
|
||||||
|
using Geekeey.Extensions.Process.Buffered;
|
||||||
|
using Geekeey.Extensions.Process.Tests.Dummy;
|
||||||
|
|
||||||
|
namespace Geekeey.Extensions.Process.Tests;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
internal sealed class ExecuteTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public async Task Execute_ExitCode_And_RunTime()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var cmd = new Command(Program.FilePath)
|
||||||
|
.WithArguments(["echo"]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await cmd.ExecuteAsync();
|
||||||
|
|
||||||
|
Assume.That(result.ExitCode, Is.EqualTo(0));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(result.ExitCode, Is.EqualTo(0));
|
||||||
|
Assert.That(result.IsSuccess, Is.True);
|
||||||
|
Assert.That(result.RunTime, Is.GreaterThan(TimeSpan.Zero));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Execute_ProcessId()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var cmd = new Command(Program.FilePath)
|
||||||
|
.WithArguments(["echo"]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var task = cmd.ExecuteAsync();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(task.ProcessId, Is.Not.EqualTo(0));
|
||||||
|
|
||||||
|
await task;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Execute_WithAwaiter()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var cmd = new Command(Program.FilePath)
|
||||||
|
.WithArguments(["echo"]);
|
||||||
|
|
||||||
|
// Act + Assert
|
||||||
|
await cmd.ExecuteAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public Task Execute_Throw_On_FileNotFound()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var cmd = new Command("some_exe_with_does_not_exits");
|
||||||
|
|
||||||
|
// Act + Assert
|
||||||
|
Assert.ThrowsAsync<Win32Exception>(async () => await cmd.ExecuteAsync());
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Execute_WithWorkingDirectory()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var dir = TestTempDirectory.Create();
|
||||||
|
|
||||||
|
var cmd = new Command(Program.FilePath)
|
||||||
|
.WithArguments("cwd")
|
||||||
|
.WithWorkingDirectory(dir.Path);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await cmd.ExecuteBufferedAsync();
|
||||||
|
|
||||||
|
Assume.That(result.ExitCode, Is.EqualTo(0));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var lines = result.StandardOutput.Split(Environment.NewLine);
|
||||||
|
Assert.That(lines, Is.SupersetOf(new[] { dir.Path }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Execute_WithEnvironmentVariables()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var cmd = new Command(Program.FilePath)
|
||||||
|
.WithArguments(["env", "foo", "bar"])
|
||||||
|
.WithEnvironmentVariables(env => env
|
||||||
|
.Set("foo", "hello")
|
||||||
|
.Set("bar", "world"));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await cmd.ExecuteBufferedAsync();
|
||||||
|
|
||||||
|
Assume.That(result.ExitCode, Is.EqualTo(0));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var lines = result.StandardOutput.Split(Environment.NewLine);
|
||||||
|
Assert.That(lines, Is.SupersetOf(new[] { "hello", "world" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Execute_WithEnvironmentVariables_Overrides()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var key = Guid.NewGuid();
|
||||||
|
var variableToKeep = $"GKY_TEST_KEEP_{key}";
|
||||||
|
var variableToOverwrite = $"GKY_TEST_OVERWRITE_{key}";
|
||||||
|
var variableToUnset = $"GKY_TEST_UNSET_{key}";
|
||||||
|
|
||||||
|
|
||||||
|
using var _a = (TestEnvironment.Create(variableToKeep, "keep")); // will be left unchanged
|
||||||
|
using var _b = (TestEnvironment.Create(variableToOverwrite, "overwrite")); // will be overwritten
|
||||||
|
using var _c = (TestEnvironment.Create(variableToUnset, "unset")); // will be unset
|
||||||
|
|
||||||
|
var cmd = new Command(Program.FilePath)
|
||||||
|
.WithArguments(["env", variableToKeep, variableToOverwrite, variableToUnset])
|
||||||
|
.WithEnvironmentVariables(env => env
|
||||||
|
.Set(variableToOverwrite, "overwritten")
|
||||||
|
.Set(variableToUnset, null));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await cmd.ExecuteBufferedAsync();
|
||||||
|
|
||||||
|
Assume.That(result.ExitCode, Is.EqualTo(0));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var lines = result.StandardOutput.Split(Environment.NewLine);
|
||||||
|
Assert.That(lines, Is.SupersetOf(new[] { "keep", "overwritten" }));
|
||||||
|
}
|
||||||
|
}
|
19
src/Process.Tests/Fixtures/TestEnvironment.cs
Normal file
19
src/Process.Tests/Fixtures/TestEnvironment.cs
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
namespace Geekeey.Extensions.Process.Tests;
|
||||||
|
|
||||||
|
internal sealed class TestEnvironment(Action action) : IDisposable
|
||||||
|
{
|
||||||
|
public static TestEnvironment Create(string name, string? value)
|
||||||
|
{
|
||||||
|
var lastValue = Environment.GetEnvironmentVariable(name);
|
||||||
|
Environment.SetEnvironmentVariable(name, value);
|
||||||
|
|
||||||
|
return new TestEnvironment(() => Environment.SetEnvironmentVariable(name, lastValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TestEnvironment ExtendPath(string path)
|
||||||
|
{
|
||||||
|
return Create("PATH", Environment.GetEnvironmentVariable("PATH") + Path.PathSeparator + path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => action();
|
||||||
|
}
|
28
src/Process.Tests/Fixtures/TestTempDirectory.cs
Normal file
28
src/Process.Tests/Fixtures/TestTempDirectory.cs
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace Geekeey.Extensions.Process.Tests;
|
||||||
|
|
||||||
|
internal sealed class TestTempDirectory(string path) : IDisposable
|
||||||
|
{
|
||||||
|
public static TestTempDirectory Create()
|
||||||
|
{
|
||||||
|
var location = Assembly.GetExecutingAssembly().Location;
|
||||||
|
var pwd = System.IO.Path.GetDirectoryName(location) ?? Directory.GetCurrentDirectory();
|
||||||
|
var dirPath = System.IO.Path.Combine(pwd, "Temp", Guid.NewGuid().ToString());
|
||||||
|
|
||||||
|
Directory.CreateDirectory(dirPath);
|
||||||
|
|
||||||
|
return new TestTempDirectory(dirPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Path { get; } = path;
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.Delete(Path, true);
|
||||||
|
}
|
||||||
|
catch (DirectoryNotFoundException) { }
|
||||||
|
}
|
||||||
|
}
|
30
src/Process.Tests/Fixtures/TestTempFile.cs
Normal file
30
src/Process.Tests/Fixtures/TestTempFile.cs
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace Geekeey.Extensions.Process.Tests;
|
||||||
|
|
||||||
|
internal sealed class TestTempFile(string path) : IDisposable
|
||||||
|
{
|
||||||
|
public static TestTempFile Create()
|
||||||
|
{
|
||||||
|
var location = Assembly.GetExecutingAssembly().Location;
|
||||||
|
var pwd = System.IO.Path.GetDirectoryName(location) ?? Directory.GetCurrentDirectory();
|
||||||
|
var dirPath = System.IO.Path.Combine(pwd, "Temp");
|
||||||
|
|
||||||
|
Directory.CreateDirectory(dirPath);
|
||||||
|
|
||||||
|
var filePath = System.IO.Path.Combine(dirPath, Guid.NewGuid() + ".tmp");
|
||||||
|
|
||||||
|
return new TestTempFile(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Path { get; } = path;
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Delete(Path);
|
||||||
|
}
|
||||||
|
catch (FileNotFoundException) { }
|
||||||
|
}
|
||||||
|
}
|
48
src/Process.Tests/PathResolutionTests.cs
Normal file
48
src/Process.Tests/PathResolutionTests.cs
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
using Geekeey.Extensions.Process.Buffered;
|
||||||
|
|
||||||
|
namespace Geekeey.Extensions.Process.Tests;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
internal sealed class PathResolutionTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public async Task Execute_CommandUsingShortName()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var cmd = new Command("dotnet")
|
||||||
|
.WithArguments("--version");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await cmd.ExecuteBufferedAsync();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(result.IsSuccess, Is.True);
|
||||||
|
Assert.That(result.StandardOutput.Trim(), Does.Match(@"^\d+\.\d+\.\d+$"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
[Platform("win")]
|
||||||
|
public async Task Execute_ScriptUsingShortName()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var dir = TestTempDirectory.Create();
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(dir.Path, "script.cmd"), "@echo hi");
|
||||||
|
|
||||||
|
using var _1 = TestEnvironment.ExtendPath(dir.Path);
|
||||||
|
var cmd = new Command("script");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await cmd.ExecuteBufferedAsync();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
// Assert
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(result.IsSuccess, Is.True);
|
||||||
|
Assert.That(result.StandardOutput.Trim(), Is.EqualTo("hi"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
127
src/Process.Tests/PipingTests.cs
Normal file
127
src/Process.Tests/PipingTests.cs
Normal file
|
@ -0,0 +1,127 @@
|
||||||
|
using Geekeey.Extensions.Process.Buffered;
|
||||||
|
|
||||||
|
namespace Geekeey.Extensions.Process.Tests;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
internal sealed class PipingTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public async Task Pipe_StdinFromAsyncAnonymousSource()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var source = PipeSource.Create(async (destination, cancellationToken)
|
||||||
|
=> await destination.WriteAsync("Hello World!"u8.ToArray(), cancellationToken));
|
||||||
|
|
||||||
|
var cmd = source | new Command(Dummy.Program.FilePath)
|
||||||
|
.WithArguments("echo-stdin");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await cmd.ExecuteBufferedAsync();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result.StandardOutput.Trim(), Is.EqualTo("Hello World!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Pipe_StdinFromSyncAnonymousSource()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var source = PipeSource.Create(destination
|
||||||
|
=> destination.Write("Hello World!"u8.ToArray()));
|
||||||
|
|
||||||
|
var cmd = source | new Command(Dummy.Program.FilePath)
|
||||||
|
.WithArguments("echo-stdin");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await cmd.ExecuteBufferedAsync();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result.StandardOutput.Trim(), Is.EqualTo("Hello World!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Pipe_StdinFromStream()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var source = new MemoryStream("Hello World!"u8.ToArray());
|
||||||
|
|
||||||
|
var cmd = source | new Command(Dummy.Program.FilePath)
|
||||||
|
.WithArguments("echo-stdin");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await cmd.ExecuteBufferedAsync();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result.StandardOutput.Trim(), Is.EqualTo("Hello World!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Pipe_StdinFromMemory()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var data = new ReadOnlyMemory<byte>("Hello World!"u8.ToArray());
|
||||||
|
|
||||||
|
var cmd = data | new Command(Dummy.Program.FilePath)
|
||||||
|
.WithArguments("echo-stdin");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await cmd.ExecuteBufferedAsync();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result.StandardOutput.Trim(), Is.EqualTo("Hello World!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Pipe_StdinFromByteArray()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var data = "Hello World!"u8.ToArray();
|
||||||
|
|
||||||
|
var cmd = data | new Command(Dummy.Program.FilePath)
|
||||||
|
.WithArguments("echo-stdin");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await cmd.ExecuteBufferedAsync();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result.StandardOutput.Trim(), Is.EqualTo("Hello World!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Pipe_StdinFromString()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var data = "Hello World!";
|
||||||
|
|
||||||
|
var cmd = data | new Command(Dummy.Program.FilePath)
|
||||||
|
.WithArguments("echo-stdin");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await cmd.ExecuteBufferedAsync();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.That(result.StandardOutput.Trim(), Is.EqualTo("Hello World!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Pipe_NoStdinToProgramWhichExpectsInput()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var cmd = new Command(Dummy.Program.FilePath)
|
||||||
|
.WithArguments("echo-stdin");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await cmd.ExecuteAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Pipe_EmptyDataToProgramWhichExpectsInput()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var cmd = Array.Empty<byte>() | new Command(Dummy.Program.FilePath)
|
||||||
|
.WithArguments("echo-stdin");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await cmd.ExecuteAsync();
|
||||||
|
}
|
||||||
|
}
|
23
src/Process.Tests/Process.Tests.csproj
Normal file
23
src/Process.Tests/Process.Tests.csproj
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="NUnit.Framework" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
|
<PackageReference Include="NUnit" />
|
||||||
|
<PackageReference Include="NUnit3TestAdapter" />
|
||||||
|
<PackageReference Include="NUnit.Analyzers" />
|
||||||
|
<PackageReference Include="coverlet.collector" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Process.Tests.Dummy\Process.Tests.Dummy.csproj" />
|
||||||
|
<ProjectReference Include="..\Process\Process.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
60
src/Process.Tests/ValidationTests.cs
Normal file
60
src/Process.Tests/ValidationTests.cs
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
using Geekeey.Extensions.Process.Buffered;
|
||||||
|
|
||||||
|
namespace Geekeey.Extensions.Process.Tests;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
internal sealed class ValidationTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public void Execute_Throws_OnNonZeroExit()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var cmd = new Command(Dummy.Program.FilePath)
|
||||||
|
.WithArguments(["exit", "1"]);
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
var exception = Assert.ThrowsAsync<CommandExecutionException>(async () => await cmd.ExecuteAsync());
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(exception.ExitCode, Is.EqualTo(1));
|
||||||
|
Assert.That(exception.Message, Does.Contain("a non-zero exit code (1)"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ExecuteBuffered_Throws_OnNonZeroExit()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var cmd = new Command(Dummy.Program.FilePath)
|
||||||
|
.WithArguments(["exit", "1"]);
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
var exception = Assert.ThrowsAsync<CommandExecutionException>(async () => await cmd.ExecuteBufferedAsync());
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(exception.ExitCode, Is.EqualTo(1));
|
||||||
|
Assert.That(exception.Message, Does.Contain("Exit code set to 1"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Execute_DoesNothingOnNone()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var cmd = new Command(Dummy.Program.FilePath)
|
||||||
|
.WithValidation(CommandExitBehaviour.None)
|
||||||
|
.WithArguments(["exit", "1"]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var exception = await cmd.ExecuteAsync();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(exception.ExitCode, Is.EqualTo(1));
|
||||||
|
Assert.That(exception.IsSuccess, Is.False);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
95
src/Process/Buffered/BufferedCommandExtensions.cs
Normal file
95
src/Process/Buffered/BufferedCommandExtensions.cs
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Geekeey.Extensions.Process.Buffered;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Buffered execution model.
|
||||||
|
/// </summary>
|
||||||
|
public static class BufferedCommandExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Executes the command asynchronously with buffering.
|
||||||
|
/// Data written to the standard output and standard error streams is decoded as text
|
||||||
|
/// and returned as part of the result object.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This method can be awaited.
|
||||||
|
/// </remarks>
|
||||||
|
public static CommandTask<BufferedCommandResult> ExecuteBufferedAsync(this Command command,
|
||||||
|
Encoding standardOutputEncoding, Encoding standardErrorEncoding, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var stdOutBuffer = new StringBuilder();
|
||||||
|
var stdErrBuffer = new StringBuilder();
|
||||||
|
|
||||||
|
var stdOutPipe = PipeTarget.Merge(
|
||||||
|
command.StandardOutputPipe,
|
||||||
|
PipeTarget.ToStringBuilder(stdOutBuffer, standardOutputEncoding)
|
||||||
|
);
|
||||||
|
|
||||||
|
var stdErrPipe = PipeTarget.Merge(
|
||||||
|
command.StandardErrorPipe,
|
||||||
|
PipeTarget.ToStringBuilder(stdErrBuffer, standardErrorEncoding)
|
||||||
|
);
|
||||||
|
|
||||||
|
var commandWithPipes = command
|
||||||
|
.WithStandardOutputPipe(stdOutPipe)
|
||||||
|
.WithStandardErrorPipe(stdErrPipe);
|
||||||
|
|
||||||
|
return commandWithPipes
|
||||||
|
.ExecuteAsync(cancellationToken)
|
||||||
|
.Bind(async task =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await task;
|
||||||
|
|
||||||
|
return new BufferedCommandResult(
|
||||||
|
result.ExitCode,
|
||||||
|
result.StartTime,
|
||||||
|
result.ExitTime,
|
||||||
|
stdOutBuffer.ToString(),
|
||||||
|
stdErrBuffer.ToString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
catch (CommandExecutionException exception)
|
||||||
|
{
|
||||||
|
var message = $"""
|
||||||
|
Command execution failed, see the inner exception for details.
|
||||||
|
|
||||||
|
Standard error:
|
||||||
|
{stdErrBuffer.ToString().Trim()}
|
||||||
|
""";
|
||||||
|
throw new CommandExecutionException(exception.Command, exception.ExitCode, message, exception);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Executes the command asynchronously with buffering.
|
||||||
|
/// Data written to the standard output and standard error streams is decoded as text
|
||||||
|
/// and returned as part of the result object.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This method can be awaited.
|
||||||
|
/// </remarks>
|
||||||
|
public static CommandTask<BufferedCommandResult> ExecuteBufferedAsync(this Command command,
|
||||||
|
Encoding encoding, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return command.ExecuteBufferedAsync(encoding, encoding, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Executes the command asynchronously with buffering.
|
||||||
|
/// Data written to the standard output and standard error streams is decoded as text
|
||||||
|
/// and returned as part of the result object.
|
||||||
|
/// Uses <see cref="Console.OutputEncoding" /> for decoding.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This method can be awaited.
|
||||||
|
/// </remarks>
|
||||||
|
public static CommandTask<BufferedCommandResult> ExecuteBufferedAsync(this Command command,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return command.ExecuteBufferedAsync(Console.OutputEncoding, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
23
src/Process/Buffered/BufferedCommandResult.cs
Normal file
23
src/Process/Buffered/BufferedCommandResult.cs
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
namespace Geekeey.Extensions.Process.Buffered;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of a command execution, with buffered text data from standard output and standard error streams.
|
||||||
|
/// </summary>
|
||||||
|
public class BufferedCommandResult(
|
||||||
|
int exitCode,
|
||||||
|
DateTimeOffset startTime,
|
||||||
|
DateTimeOffset exitTime,
|
||||||
|
string standardOutput,
|
||||||
|
string standardError
|
||||||
|
) : CommandResult(exitCode, startTime, exitTime)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Standard output data produced by the underlying process.
|
||||||
|
/// </summary>
|
||||||
|
public string StandardOutput { get; } = standardOutput;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Standard error data produced by the underlying process.
|
||||||
|
/// </summary>
|
||||||
|
public string StandardError { get; } = standardError;
|
||||||
|
}
|
137
src/Process/Builders/ArgumentsBuilder.cs
Normal file
137
src/Process/Builders/ArgumentsBuilder.cs
Normal file
|
@ -0,0 +1,137 @@
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Geekeey.Extensions.Process;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builder that helps format command-line arguments into a string.
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class ArgumentsBuilder
|
||||||
|
{
|
||||||
|
private static readonly IFormatProvider DefaultFormatProvider = CultureInfo.InvariantCulture;
|
||||||
|
|
||||||
|
private readonly StringBuilder _buffer = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds the specified value to the list of arguments.
|
||||||
|
/// </summary>
|
||||||
|
public ArgumentsBuilder Add(string value, bool escape = true)
|
||||||
|
{
|
||||||
|
if (_buffer.Length > 0)
|
||||||
|
_buffer.Append(' ');
|
||||||
|
|
||||||
|
_buffer.Append(escape ? Escape(value) : value);
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds the specified values to the list of arguments.
|
||||||
|
/// </summary>
|
||||||
|
public ArgumentsBuilder Add(IEnumerable<string> values, bool escape = true)
|
||||||
|
{
|
||||||
|
foreach (var value in values)
|
||||||
|
{
|
||||||
|
Add(value, escape);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds the specified value to the list of arguments.
|
||||||
|
/// </summary>
|
||||||
|
public ArgumentsBuilder Add(IFormattable value, IFormatProvider formatProvider, bool escape = true)
|
||||||
|
=> Add(value.ToString(null, formatProvider), escape);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds the specified value to the list of arguments.
|
||||||
|
/// The value is converted to string using invariant culture.
|
||||||
|
/// </summary>
|
||||||
|
public ArgumentsBuilder Add(IFormattable value, bool escape = true)
|
||||||
|
=> Add(value, DefaultFormatProvider, escape);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds the specified values to the list of arguments.
|
||||||
|
/// </summary>
|
||||||
|
public ArgumentsBuilder Add(IEnumerable<IFormattable> values, IFormatProvider formatProvider, bool escape = true)
|
||||||
|
{
|
||||||
|
foreach (var value in values)
|
||||||
|
{
|
||||||
|
Add(value, formatProvider, escape);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds the specified values to the list of arguments.
|
||||||
|
/// The values are converted to string using invariant culture.
|
||||||
|
/// </summary>
|
||||||
|
public ArgumentsBuilder Add(IEnumerable<IFormattable> values, bool escape = true)
|
||||||
|
=> Add(values, DefaultFormatProvider, escape);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the resulting arguments string.
|
||||||
|
/// </summary>
|
||||||
|
public string Build() => _buffer.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class ArgumentsBuilder
|
||||||
|
{
|
||||||
|
private static string Escape(string argument)
|
||||||
|
{
|
||||||
|
// Short circuit if the argument is clean and doesn't need escaping
|
||||||
|
if (argument.Length > 0 && argument.All(c => !char.IsWhiteSpace(c) && c is not '"'))
|
||||||
|
return argument;
|
||||||
|
|
||||||
|
var buffer = new StringBuilder();
|
||||||
|
|
||||||
|
buffer.Append('"');
|
||||||
|
|
||||||
|
for (var i = 0; i < argument.Length;)
|
||||||
|
{
|
||||||
|
var c = argument[i++];
|
||||||
|
|
||||||
|
switch (c)
|
||||||
|
{
|
||||||
|
case '\\':
|
||||||
|
{
|
||||||
|
var backslashCount = 1;
|
||||||
|
while (i < argument.Length && argument[i] == '\\')
|
||||||
|
{
|
||||||
|
backslashCount++;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i == argument.Length)
|
||||||
|
{
|
||||||
|
buffer.Append('\\', backslashCount * 2);
|
||||||
|
}
|
||||||
|
else if (argument[i] == '"')
|
||||||
|
{
|
||||||
|
buffer.Append('\\', backslashCount * 2 + 1).Append('"');
|
||||||
|
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
buffer.Append('\\', backslashCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case '"':
|
||||||
|
buffer.Append('\\').Append('"');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
buffer.Append(c);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.Append('"');
|
||||||
|
|
||||||
|
return buffer.ToString();
|
||||||
|
}
|
||||||
|
}
|
43
src/Process/Builders/EnvironmentVariablesBuilder.cs
Normal file
43
src/Process/Builders/EnvironmentVariablesBuilder.cs
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
namespace Geekeey.Extensions.Process;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builder that helps configure environment variables.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EnvironmentVariablesBuilder
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, string?> _vars = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets an environment variable with the specified name to the specified value.
|
||||||
|
/// </summary>
|
||||||
|
public EnvironmentVariablesBuilder Set(string name, string? value)
|
||||||
|
{
|
||||||
|
_vars[name] = value;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets multiple environment variables from the specified sequence of key-value pairs.
|
||||||
|
/// </summary>
|
||||||
|
public EnvironmentVariablesBuilder Set(IEnumerable<KeyValuePair<string, string?>> variables)
|
||||||
|
{
|
||||||
|
foreach (var (name, value) in variables)
|
||||||
|
{
|
||||||
|
Set(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets multiple environment variables from the specified dictionary.
|
||||||
|
/// </summary>
|
||||||
|
public EnvironmentVariablesBuilder Set(IReadOnlyDictionary<string, string?> variables)
|
||||||
|
=> Set((IEnumerable<KeyValuePair<string, string?>>)variables);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the resulting environment variables.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyDictionary<string, string?> Build()
|
||||||
|
=> new Dictionary<string, string?>(_vars, _vars.Comparer);
|
||||||
|
}
|
113
src/Process/Command.Builder.cs
Normal file
113
src/Process/Command.Builder.cs
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Diagnostics.Contracts;
|
||||||
|
|
||||||
|
namespace Geekeey.Extensions.Process;
|
||||||
|
|
||||||
|
public sealed partial class Command
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a copy of this command, setting the target file path to the specified value.
|
||||||
|
/// </summary>
|
||||||
|
[Pure]
|
||||||
|
public Command WithTargetFile(string targetFilePath)
|
||||||
|
=> new(targetFilePath, Arguments, WorkingDirPath, EnvironmentVariables, Validation,
|
||||||
|
StandardInputPipe, StandardOutputPipe, StandardErrorPipe);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a copy of this command, setting the arguments to the specified value.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Avoid using this overload, as it requires the arguments to be escaped manually.
|
||||||
|
/// Formatting errors may lead to unexpected bugs and security vulnerabilities.
|
||||||
|
/// </remarks>
|
||||||
|
[Pure]
|
||||||
|
public Command WithArguments(string arguments)
|
||||||
|
=> new(TargetFilePath, arguments, WorkingDirPath, EnvironmentVariables, Validation,
|
||||||
|
StandardInputPipe, StandardOutputPipe, StandardErrorPipe);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a copy of this command, setting the arguments to the value
|
||||||
|
/// obtained by formatting the specified enumeration.
|
||||||
|
/// </summary>
|
||||||
|
[Pure]
|
||||||
|
public Command WithArguments(IEnumerable<string> arguments, bool escape = true)
|
||||||
|
=> WithArguments(args => args.Add(arguments, escape));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a copy of this command, setting the arguments to the value
|
||||||
|
/// configured by the specified delegate.
|
||||||
|
/// </summary>
|
||||||
|
[Pure]
|
||||||
|
public Command WithArguments(Action<ArgumentsBuilder> configure)
|
||||||
|
{
|
||||||
|
var builder = new ArgumentsBuilder();
|
||||||
|
configure(builder);
|
||||||
|
|
||||||
|
return WithArguments(builder.Build());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a copy of this command, setting the working directory path to the specified value.
|
||||||
|
/// </summary>
|
||||||
|
[Pure]
|
||||||
|
public Command WithWorkingDirectory(string workingDirPath)
|
||||||
|
=> new(TargetFilePath, Arguments, workingDirPath, EnvironmentVariables, Validation,
|
||||||
|
StandardInputPipe, StandardOutputPipe, StandardErrorPipe);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a copy of this command, setting the environment variables to the specified value.
|
||||||
|
/// </summary>
|
||||||
|
[Pure]
|
||||||
|
public Command WithEnvironmentVariables(IReadOnlyDictionary<string, string?> environmentVariables)
|
||||||
|
=> new(TargetFilePath, Arguments, WorkingDirPath, environmentVariables, Validation,
|
||||||
|
StandardInputPipe, StandardOutputPipe, StandardErrorPipe);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a copy of this command, setting the environment variables to the value
|
||||||
|
/// configured by the specified delegate.
|
||||||
|
/// </summary>
|
||||||
|
[Pure]
|
||||||
|
public Command WithEnvironmentVariables(Action<EnvironmentVariablesBuilder> configure)
|
||||||
|
{
|
||||||
|
var builder = new EnvironmentVariablesBuilder();
|
||||||
|
configure(builder);
|
||||||
|
|
||||||
|
return WithEnvironmentVariables(builder.Build());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a copy of this command, setting the validation options to the specified value.
|
||||||
|
/// </summary>
|
||||||
|
[Pure]
|
||||||
|
public Command WithValidation(CommandExitBehaviour exitBehaviour)
|
||||||
|
=> new(TargetFilePath, Arguments, WorkingDirPath, EnvironmentVariables, exitBehaviour,
|
||||||
|
StandardInputPipe, StandardOutputPipe, StandardErrorPipe);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a copy of this command, setting the standard input pipe to the specified source.
|
||||||
|
/// </summary>
|
||||||
|
[Pure]
|
||||||
|
public Command WithStandardInputPipe(PipeSource source)
|
||||||
|
=> new(TargetFilePath, Arguments, WorkingDirPath, EnvironmentVariables, Validation,
|
||||||
|
source, StandardOutputPipe, StandardErrorPipe);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a copy of this command, setting the standard output pipe to the specified target.
|
||||||
|
/// </summary>
|
||||||
|
[Pure]
|
||||||
|
public Command WithStandardOutputPipe(PipeTarget target)
|
||||||
|
=> new(TargetFilePath, Arguments, WorkingDirPath, EnvironmentVariables, Validation,
|
||||||
|
StandardInputPipe, target, StandardErrorPipe);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a copy of this command, setting the standard error pipe to the specified target.
|
||||||
|
/// </summary>
|
||||||
|
[Pure]
|
||||||
|
public Command WithStandardErrorPipe(PipeTarget target)
|
||||||
|
=> new(TargetFilePath, Arguments, WorkingDirPath, EnvironmentVariables, Validation,
|
||||||
|
StandardInputPipe, StandardOutputPipe, target);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
[ExcludeFromCodeCoverage]
|
||||||
|
public override string ToString() => $"{TargetFilePath} {Arguments}";
|
||||||
|
}
|
212
src/Process/Command.Execute.cs
Normal file
212
src/Process/Command.Execute.cs
Normal file
|
@ -0,0 +1,212 @@
|
||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace Geekeey.Extensions.Process;
|
||||||
|
|
||||||
|
public partial class Command
|
||||||
|
{
|
||||||
|
private static readonly Lazy<string?> ProcessPathLazy = new(() =>
|
||||||
|
{
|
||||||
|
using var process = System.Diagnostics.Process.GetCurrentProcess();
|
||||||
|
return process.MainModule?.FileName;
|
||||||
|
});
|
||||||
|
|
||||||
|
private static readonly string[] WindowsExecutableExtensions = ["exe", "cmd", "bat"];
|
||||||
|
private static readonly TimeSpan CancelWaitTimeout = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
|
private static string? ProcessPath => ProcessPathLazy.Value;
|
||||||
|
|
||||||
|
private ProcessStartInfo CreateStartInfo()
|
||||||
|
{
|
||||||
|
var info = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = GetOptimallyQualifiedTargetFilePath(),
|
||||||
|
Arguments = Arguments,
|
||||||
|
WorkingDirectory = WorkingDirPath,
|
||||||
|
RedirectStandardInput = true,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
// This option only works on Windows and is required there to prevent the
|
||||||
|
// child processes from attaching to the parent console window, if one exists.
|
||||||
|
// We need this in order to be able to send signals to one specific child process,
|
||||||
|
// without affecting any others that may also be running in parallel.
|
||||||
|
CreateNoWindow = true
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set environment variables
|
||||||
|
foreach (var (key, value) in EnvironmentVariables)
|
||||||
|
{
|
||||||
|
if (value is not null)
|
||||||
|
{
|
||||||
|
info.Environment[key] = value;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Null value means we should remove the variable
|
||||||
|
info.Environment.Remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return info;
|
||||||
|
|
||||||
|
string GetOptimallyQualifiedTargetFilePath()
|
||||||
|
{
|
||||||
|
// Currently, we only need this workaround for script files on Windows, so short-circuit
|
||||||
|
// if we are on a different platform.
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
return TargetFilePath;
|
||||||
|
|
||||||
|
// Don't do anything for fully qualified paths or paths that already have an extension specified.
|
||||||
|
// System.Diagnostics.Process knows how to handle those without our help.
|
||||||
|
if (Path.IsPathFullyQualified(TargetFilePath) ||
|
||||||
|
!string.IsNullOrWhiteSpace(Path.GetExtension(TargetFilePath)))
|
||||||
|
return TargetFilePath;
|
||||||
|
|
||||||
|
return (
|
||||||
|
from probeDirPath in GetProbeDirectoryPaths()
|
||||||
|
where Directory.Exists(probeDirPath)
|
||||||
|
select Path.Combine(probeDirPath, TargetFilePath)
|
||||||
|
into baseFilePath
|
||||||
|
from extension in WindowsExecutableExtensions
|
||||||
|
select Path.ChangeExtension(baseFilePath, extension)
|
||||||
|
).FirstOrDefault(File.Exists) ?? TargetFilePath;
|
||||||
|
|
||||||
|
static IEnumerable<string> GetProbeDirectoryPaths()
|
||||||
|
{
|
||||||
|
// Executable directory
|
||||||
|
if (!string.IsNullOrWhiteSpace(ProcessPath))
|
||||||
|
{
|
||||||
|
var processDirPath = Path.GetDirectoryName(ProcessPath);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(processDirPath))
|
||||||
|
yield return processDirPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Working directory
|
||||||
|
yield return Directory.GetCurrentDirectory();
|
||||||
|
|
||||||
|
// Directories on the PATH
|
||||||
|
if (Environment.GetEnvironmentVariable("PATH")?.Split(Path.PathSeparator) is { } paths)
|
||||||
|
{
|
||||||
|
foreach (var path in paths)
|
||||||
|
{
|
||||||
|
yield return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Executes the command asynchronously.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This method can be awaited.
|
||||||
|
/// </remarks>
|
||||||
|
public CommandTask<CommandResult> ExecuteAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var process = new ProcessHandle(CreateStartInfo());
|
||||||
|
process.Start();
|
||||||
|
|
||||||
|
// Extract the process ID before calling ExecuteAsync(), because the process may
|
||||||
|
// already be disposed by then.
|
||||||
|
var processId = process.Id;
|
||||||
|
|
||||||
|
var task = ExecuteAsync(process, cancellationToken);
|
||||||
|
return new CommandTask<CommandResult>(task, processId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> ExecuteAsync(ProcessHandle process, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var _ = process;
|
||||||
|
|
||||||
|
// Additional cancellation for the stdin pipe in case the process exits without fully exhausting it
|
||||||
|
using var stdin = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
using var complete = new CancellationTokenSource();
|
||||||
|
|
||||||
|
// ReSharper disable once AccessToDisposedClosure
|
||||||
|
await using (cancellationToken.Register(() => complete.CancelAfter(CancelWaitTimeout)))
|
||||||
|
await using (cancellationToken.Register(process.Kill))
|
||||||
|
{
|
||||||
|
var writing = PipeStdInAsync(process, stdin.Token);
|
||||||
|
var reading = Task.WhenAll(
|
||||||
|
PipeStdOutAsync(process, cancellationToken),
|
||||||
|
PipeStdErrAsync(process, cancellationToken));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Wait until the process exits normally or gets killed.
|
||||||
|
// The timeout is started after the execution is forcefully canceled and ensures
|
||||||
|
// that we don't wait forever in case the attempt to kill the process failed.
|
||||||
|
await process.WaitUntilExitAsync(complete.Token);
|
||||||
|
|
||||||
|
// Send the cancellation signal to the stdin pipe since the process has exited
|
||||||
|
// and won't need it anymore.
|
||||||
|
// If the pipe has already been exhausted (most likely), this won't do anything.
|
||||||
|
// If the pipe is still trying to transfer data, this will cause it to abort.
|
||||||
|
await stdin.CancelAsync();
|
||||||
|
|
||||||
|
// Wait until piping is done and propagate exceptions.
|
||||||
|
await Task.WhenAll(writing, reading);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException exception) when (ShouldSwallowException(exception))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ShouldSwallowException(OperationCanceledException exception)
|
||||||
|
=> exception.CancellationToken == cancellationToken ||
|
||||||
|
exception.CancellationToken == complete.Token ||
|
||||||
|
exception.CancellationToken == stdin.Token;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.ExitCode is not 0 && Validation.HasFlag(CommandExitBehaviour.ZeroExitCode))
|
||||||
|
{
|
||||||
|
var message = $"Command execution failed because the underlying process ({process.Name}#{process.Id}) " +
|
||||||
|
$"returned a non-zero exit code ({process.ExitCode}).";
|
||||||
|
throw new CommandExecutionException(this, process.ExitCode, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CommandResult(process.ExitCode, process.StartTime, process.ExitTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PipeStdOutAsync(ProcessHandle process, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using (process.StandardOutput)
|
||||||
|
{
|
||||||
|
await StandardOutputPipe.CopyFromAsync(process.StandardOutput, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PipeStdErrAsync(ProcessHandle process, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using (process.StandardError)
|
||||||
|
{
|
||||||
|
await StandardErrorPipe.CopyFromAsync(process.StandardError, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PipeStdInAsync(ProcessHandle process, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using (process.StandardInput)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Some streams do not support cancellation, so we add a fallback that
|
||||||
|
// drops the task and returns early.
|
||||||
|
// This is important with stdin because the process might finish before
|
||||||
|
// the pipe has been fully exhausted, and we don't want to wait for it.
|
||||||
|
await StandardInputPipe.CopyToAsync(process.StandardInput, cancellationToken)
|
||||||
|
.WaitAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
// Expect IOException: "The pipe has been ended" (Windows) or "Broken pipe" (Unix).
|
||||||
|
// This may happen if the process terminated before the pipe has been exhausted.
|
||||||
|
// It's not an exceptional situation because the process may not need the entire
|
||||||
|
// stdin to complete successfully.
|
||||||
|
// We also can't rely on process.HasExited here because of potential race conditions.
|
||||||
|
catch (IOException ex) when (ex.GetType() == typeof(IOException))
|
||||||
|
{
|
||||||
|
// Don't catch derived exceptions, such as FileNotFoundException, to avoid false positives.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
145
src/Process/Command.Piping.cs
Normal file
145
src/Process/Command.Piping.cs
Normal file
|
@ -0,0 +1,145 @@
|
||||||
|
using System.Diagnostics.Contracts;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Geekeey.Extensions.Process;
|
||||||
|
|
||||||
|
public partial class Command
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new command that pipes its standard output to the specified target.
|
||||||
|
/// </summary>
|
||||||
|
[Pure]
|
||||||
|
public static Command operator |(Command source, PipeTarget target)
|
||||||
|
=> source.WithStandardOutputPipe(target);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new command that pipes its standard output to the specified string builder.
|
||||||
|
/// Uses <see cref="Console.OutputEncoding" /> for decoding.
|
||||||
|
/// </summary>
|
||||||
|
[Pure]
|
||||||
|
public static Command operator |(Command source, StringBuilder target)
|
||||||
|
=> source | PipeTarget.ToStringBuilder(target, Console.OutputEncoding);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new command that pipes its standard output line-by-line to the specified
|
||||||
|
/// asynchronous delegate.
|
||||||
|
/// Uses <see cref="Console.OutputEncoding" /> for decoding.
|
||||||
|
/// </summary>
|
||||||
|
[Pure]
|
||||||
|
public static Command operator |(Command source, Func<string, CancellationToken, Task> target)
|
||||||
|
=> source | PipeTarget.ToDelegate(target, Console.OutputEncoding);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new command that pipes its standard output line-by-line to the specified
|
||||||
|
/// asynchronous delegate.
|
||||||
|
/// Uses <see cref="Console.OutputEncoding" /> for decoding.
|
||||||
|
/// </summary>
|
||||||
|
[Pure]
|
||||||
|
public static Command operator |(Command source, Func<string, Task> target)
|
||||||
|
=> source | PipeTarget.ToDelegate(target, Console.OutputEncoding);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new command that pipes its standard output line-by-line to the specified
|
||||||
|
/// synchronous delegate.
|
||||||
|
/// Uses <see cref="Console.OutputEncoding" /> for decoding.
|
||||||
|
/// </summary>
|
||||||
|
[Pure]
|
||||||
|
public static Command operator |(Command source, Action<string> target)
|
||||||
|
=> source | PipeTarget.ToDelegate(target, Console.OutputEncoding);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new command that pipes its standard output and standard error to the
|
||||||
|
/// specified targets.
|
||||||
|
/// </summary>
|
||||||
|
[Pure]
|
||||||
|
public static Command operator |(Command source, (PipeTarget stdOut, PipeTarget stdErr) targets)
|
||||||
|
=> source.WithStandardOutputPipe(targets.stdOut).WithStandardErrorPipe(targets.stdErr);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new command that pipes its standard output and standard error to the
|
||||||
|
/// specified streams.
|
||||||
|
/// </summary>
|
||||||
|
[Pure]
|
||||||
|
public static Command operator |(Command source, (Stream stdOut, Stream stdErr) targets)
|
||||||
|
=> source | (PipeTarget.ToStream(targets.stdOut), PipeTarget.ToStream(targets.stdErr));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new command that pipes its standard output and standard error to the
|
||||||
|
/// specified string builders.
|
||||||
|
/// Uses <see cref="Console.OutputEncoding" /> for decoding.
|
||||||
|
/// </summary>
|
||||||
|
[Pure]
|
||||||
|
public static Command operator |(Command source, (StringBuilder stdOut, StringBuilder stdErr) targets)
|
||||||
|
=> source | (PipeTarget.ToStringBuilder(targets.stdOut, Console.OutputEncoding), PipeTarget.ToStringBuilder(targets.stdErr, Console.OutputEncoding));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new command that pipes its standard output and standard error line-by-line
|
||||||
|
/// to the specified asynchronous delegates.
|
||||||
|
/// Uses <see cref="Console.OutputEncoding" /> for decoding.
|
||||||
|
/// </summary>
|
||||||
|
[Pure]
|
||||||
|
public static Command operator |(Command source, (Func<string, CancellationToken, Task> stdOut, Func<string, CancellationToken, Task> stdErr) targets)
|
||||||
|
=> source | (PipeTarget.ToDelegate(targets.stdOut, Console.OutputEncoding), PipeTarget.ToDelegate(targets.stdErr, Console.OutputEncoding));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new command that pipes its standard output and standard error line-by-line
|
||||||
|
/// to the specified asynchronous delegates.
|
||||||
|
/// Uses <see cref="Console.OutputEncoding" /> for decoding.
|
||||||
|
/// </summary>
|
||||||
|
[Pure]
|
||||||
|
public static Command operator |(Command source, (Func<string, Task> stdOut, Func<string, Task> stdErr) targets)
|
||||||
|
=> source | (PipeTarget.ToDelegate(targets.stdOut, Console.OutputEncoding), PipeTarget.ToDelegate(targets.stdErr, Console.OutputEncoding));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new command that pipes its standard output and standard error line-by-line
|
||||||
|
/// to the specified synchronous delegates.
|
||||||
|
/// Uses <see cref="Console.OutputEncoding" /> for decoding.
|
||||||
|
/// </summary>
|
||||||
|
[Pure]
|
||||||
|
public static Command operator |(Command source, (Action<string> stdOut, Action<string> stdErr) targets)
|
||||||
|
=> source | (PipeTarget.ToDelegate(targets.stdOut, Console.OutputEncoding), PipeTarget.ToDelegate(targets.stdErr, Console.OutputEncoding));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new command that pipes its standard input from the specified source.
|
||||||
|
/// </summary>
|
||||||
|
[Pure]
|
||||||
|
public static Command operator |(PipeSource source, Command target)
|
||||||
|
=> target.WithStandardInputPipe(source);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new command that pipes its standard input from the specified stream.
|
||||||
|
/// </summary>
|
||||||
|
[Pure]
|
||||||
|
public static Command operator |(Stream source, Command target)
|
||||||
|
=> PipeSource.FromStream(source) | target;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new command that pipes its standard input from the specified memory buffer.
|
||||||
|
/// </summary>
|
||||||
|
[Pure]
|
||||||
|
public static Command operator |(ReadOnlyMemory<byte> source, Command target)
|
||||||
|
=> PipeSource.FromBytes(source) | target;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new command that pipes its standard input from the specified byte array.
|
||||||
|
/// </summary>
|
||||||
|
[Pure]
|
||||||
|
public static Command operator |(byte[] source, Command target)
|
||||||
|
=> PipeSource.FromBytes(source) | target;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new command that pipes its standard input from the specified string.
|
||||||
|
/// Uses <see cref="Console.InputEncoding" /> for encoding.
|
||||||
|
/// </summary>
|
||||||
|
[Pure]
|
||||||
|
public static Command operator |(string source, Command target)
|
||||||
|
=> PipeSource.FromString(source, Console.InputEncoding) | target;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new command that pipes its standard input from the standard output of the
|
||||||
|
/// specified command.
|
||||||
|
/// </summary>
|
||||||
|
[Pure]
|
||||||
|
public static Command operator |(Command source, Command target)
|
||||||
|
=> PipeSource.FromCommand(source) | target;
|
||||||
|
}
|
65
src/Process/Command.cs
Normal file
65
src/Process/Command.cs
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
namespace Geekeey.Extensions.Process;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Instructions for running a process.
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class Command(
|
||||||
|
string targetFilePath,
|
||||||
|
string arguments,
|
||||||
|
string workingDirPath,
|
||||||
|
IReadOnlyDictionary<string, string?> environmentVariables,
|
||||||
|
CommandExitBehaviour validation,
|
||||||
|
PipeSource standardInputPipe,
|
||||||
|
PipeTarget standardOutputPipe,
|
||||||
|
PipeTarget standardErrorPipe
|
||||||
|
)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes an instance of <see cref="Command" />.
|
||||||
|
/// </summary>
|
||||||
|
public Command(string targetFilePath) : this(targetFilePath, string.Empty, Directory.GetCurrentDirectory(),
|
||||||
|
new Dictionary<string, string?>(),
|
||||||
|
CommandExitBehaviour.ZeroExitCode, PipeSource.Null, PipeTarget.Null, PipeTarget.Null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// File path of the executable, batch file, or script, that this command runs.
|
||||||
|
/// </summary>
|
||||||
|
public string TargetFilePath { get; } = targetFilePath;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// File path of the executable, batch file, or script, that this command runs.
|
||||||
|
/// </summary>
|
||||||
|
public string Arguments { get; } = arguments;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// File path of the executable, batch file, or script, that this command runs.
|
||||||
|
/// </summary>
|
||||||
|
public string WorkingDirPath { get; } = workingDirPath;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Environment variables set for the underlying process.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyDictionary<string, string?> EnvironmentVariables { get; } = environmentVariables;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Strategy for validating the result of the execution.
|
||||||
|
/// </summary>
|
||||||
|
public CommandExitBehaviour Validation { get; } = validation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pipe source for the standard input stream of the underlying process.
|
||||||
|
/// </summary>
|
||||||
|
public PipeSource StandardInputPipe { get; } = standardInputPipe;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pipe target for the standard output stream of the underlying process.
|
||||||
|
/// </summary>
|
||||||
|
public PipeTarget StandardOutputPipe { get; } = standardOutputPipe;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pipe target for the standard error stream of the underlying process.
|
||||||
|
/// </summary>
|
||||||
|
public PipeTarget StandardErrorPipe { get; } = standardErrorPipe;
|
||||||
|
}
|
23
src/Process/CommandExecutionException.cs
Normal file
23
src/Process/CommandExecutionException.cs
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
using Geekeey.Extensions.Process;
|
||||||
|
|
||||||
|
namespace Geekeey.Extensions.Process;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exception thrown when the command fails to execute correctly.
|
||||||
|
/// </summary>
|
||||||
|
public class CommandExecutionException(
|
||||||
|
Command command,
|
||||||
|
int exitCode,
|
||||||
|
string message,
|
||||||
|
Exception? innerException = null) : Exception(message, innerException)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Command that triggered the exception.
|
||||||
|
/// </summary>
|
||||||
|
public Command Command { get; } = command;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exit code returned by the process.
|
||||||
|
/// </summary>
|
||||||
|
public int ExitCode { get; } = exitCode;
|
||||||
|
}
|
18
src/Process/CommandExitBehaviour.cs
Normal file
18
src/Process/CommandExitBehaviour.cs
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
namespace Geekeey.Extensions.Process;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Strategy used for validating the result of a command execution.
|
||||||
|
/// </summary>
|
||||||
|
[Flags]
|
||||||
|
public enum CommandExitBehaviour
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// No validation.
|
||||||
|
/// </summary>
|
||||||
|
None = 0b0,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ensure that the command returned a zero exit code.
|
||||||
|
/// </summary>
|
||||||
|
ZeroExitCode = 0b1
|
||||||
|
}
|
35
src/Process/CommandResult.cs
Normal file
35
src/Process/CommandResult.cs
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
namespace Geekeey.Extensions.Process;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the result of a command execution.
|
||||||
|
/// </summary>
|
||||||
|
public class CommandResult(
|
||||||
|
int exitCode,
|
||||||
|
DateTimeOffset startTime,
|
||||||
|
DateTimeOffset exitTime)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Exit code set by the underlying process.
|
||||||
|
/// </summary>
|
||||||
|
public int ExitCode { get; } = exitCode;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the command execution was successful (i.e. exit code is zero).
|
||||||
|
/// </summary>
|
||||||
|
public bool IsSuccess => ExitCode is 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Time at which the command started executing.
|
||||||
|
/// </summary>
|
||||||
|
public DateTimeOffset StartTime { get; } = startTime;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Time at which the command finished executing.
|
||||||
|
/// </summary>
|
||||||
|
public DateTimeOffset ExitTime { get; } = exitTime;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Total duration of the command execution.
|
||||||
|
/// </summary>
|
||||||
|
public TimeSpan RunTime => ExitTime - StartTime;
|
||||||
|
}
|
58
src/Process/CommandTask.cs
Normal file
58
src/Process/CommandTask.cs
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace Geekeey.Extensions.Process;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents an asynchronous execution of a command.
|
||||||
|
/// </summary>
|
||||||
|
public partial class CommandTask<TResult> : IDisposable
|
||||||
|
{
|
||||||
|
internal CommandTask(Task<TResult> task, int processId)
|
||||||
|
{
|
||||||
|
Task = task;
|
||||||
|
ProcessId = processId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Underlying task.
|
||||||
|
/// </summary>
|
||||||
|
public Task<TResult> Task { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Underlying process ID.
|
||||||
|
/// </summary>
|
||||||
|
public int ProcessId { get; }
|
||||||
|
|
||||||
|
internal CommandTask<T> Bind<T>(Func<Task<TResult>, Task<T>> transform)
|
||||||
|
=> new(transform(Task), ProcessId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lazily maps the result of the task using the specified transform.
|
||||||
|
/// </summary>
|
||||||
|
internal CommandTask<T> Select<T>(Func<TResult, T> transform)
|
||||||
|
=> Bind(async task => transform(await task));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the awaiter of the underlying task.
|
||||||
|
/// Used to enable await expressions on this object.
|
||||||
|
/// </summary>
|
||||||
|
public TaskAwaiter<TResult> GetAwaiter()
|
||||||
|
=> Task.GetAwaiter();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures an awaiter used to await this task.
|
||||||
|
/// </summary>
|
||||||
|
public ConfiguredTaskAwaitable<TResult> ConfigureAwait(bool continueOnCapturedContext)
|
||||||
|
=> Task.ConfigureAwait(continueOnCapturedContext);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Dispose() => Task.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class CommandTask<TResult>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Converts the command task into a regular task.
|
||||||
|
/// </summary>
|
||||||
|
public static implicit operator Task<TResult>(CommandTask<TResult> commandTask) => commandTask.Task;
|
||||||
|
}
|
30
src/Process/Execution/ProcessHandle.Posix.cs
Normal file
30
src/Process/Execution/ProcessHandle.Posix.cs
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Runtime.Versioning;
|
||||||
|
|
||||||
|
namespace Geekeey.Extensions.Process;
|
||||||
|
|
||||||
|
internal partial class ProcessHandle
|
||||||
|
{
|
||||||
|
[SupportedOSPlatform("freebsd")]
|
||||||
|
[SupportedOSPlatform("linux")]
|
||||||
|
[SupportedOSPlatform("macOS")]
|
||||||
|
private bool SendPosixSignal(PosixSignals signal)
|
||||||
|
{
|
||||||
|
return Posix.Kill(Id, (int)signal) is 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[SupportedOSPlatform("freebsd")]
|
||||||
|
[SupportedOSPlatform("linux")]
|
||||||
|
[SupportedOSPlatform("macOS")]
|
||||||
|
internal static partial class Posix
|
||||||
|
{
|
||||||
|
[LibraryImport("libc", EntryPoint = "kill", SetLastError = true)]
|
||||||
|
internal static partial int Kill(int pid, int sig);
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum PosixSignals : int
|
||||||
|
{
|
||||||
|
SIGINT = 2,
|
||||||
|
SIGTERM = 15
|
||||||
|
}
|
||||||
|
}
|
19
src/Process/Execution/ProcessHandle.Windows.cs
Normal file
19
src/Process/Execution/ProcessHandle.Windows.cs
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
using System.Runtime.Versioning;
|
||||||
|
|
||||||
|
namespace Geekeey.Extensions.Process;
|
||||||
|
|
||||||
|
internal partial class ProcessHandle
|
||||||
|
{
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
private bool SendCtrlSignal(ConsoleCtrlEvent signal)
|
||||||
|
{
|
||||||
|
// TODO: find a way to implement this correctly
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum ConsoleCtrlEvent
|
||||||
|
{
|
||||||
|
CTRL_C = 0,
|
||||||
|
CTRL_BREAK = 1
|
||||||
|
}
|
||||||
|
}
|
113
src/Process/Execution/ProcessHandle.cs
Normal file
113
src/Process/Execution/ProcessHandle.cs
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace Geekeey.Extensions.Process;
|
||||||
|
|
||||||
|
internal sealed partial class ProcessHandle(ProcessStartInfo startInfo) : IDisposable
|
||||||
|
{
|
||||||
|
private readonly System.Diagnostics.Process _process = new() { StartInfo = startInfo };
|
||||||
|
private readonly TaskCompletionSource<object?> _exitTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
public int Id => _process.Id;
|
||||||
|
|
||||||
|
// we have to keep track of ProcessName ourselves because it becomes inaccessible after the process exits
|
||||||
|
public string Name => Path.GetFileName(_process.StartInfo.FileName);
|
||||||
|
|
||||||
|
// we are purposely using Stream instead of StreamWriter/StreamReader to push the concerns of
|
||||||
|
// writing and reading to PipeSource/PipeTarget at the higher level.
|
||||||
|
public Stream StandardInput => _process.StandardInput.BaseStream;
|
||||||
|
|
||||||
|
public Stream StandardOutput => _process.StandardOutput.BaseStream;
|
||||||
|
|
||||||
|
public Stream StandardError => _process.StandardError.BaseStream;
|
||||||
|
|
||||||
|
// we have to keep track of StartTime ourselves because it becomes inaccessible after the process exits
|
||||||
|
public DateTimeOffset StartTime { get; private set; }
|
||||||
|
|
||||||
|
// we have to keep track of ExitTime ourselves because it becomes inaccessible after the process exits
|
||||||
|
public DateTimeOffset ExitTime { get; private set; }
|
||||||
|
|
||||||
|
public int ExitCode => _process.ExitCode;
|
||||||
|
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
_process.EnableRaisingEvents = true;
|
||||||
|
_process.Exited += (_, _) =>
|
||||||
|
{
|
||||||
|
ExitTime = DateTimeOffset.Now;
|
||||||
|
_exitTcs.TrySetResult(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!_process.Start())
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Failed to start a process with file path '{_process.StartInfo.FileName}'. " +
|
||||||
|
$"Target file is not an executable or lacks execute permissions.");
|
||||||
|
}
|
||||||
|
|
||||||
|
StartTime = DateTimeOffset.Now;
|
||||||
|
}
|
||||||
|
catch (Win32Exception exception)
|
||||||
|
{
|
||||||
|
throw new Win32Exception(
|
||||||
|
$"Failed to start a process with file path '{_process.StartInfo.FileName}'. " +
|
||||||
|
$"Target file or working directory doesn't exist, or the provided credentials are invalid.", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Interrupt()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
if (SendCtrlSignal(ConsoleCtrlEvent.CTRL_C))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (SendCtrlSignal(ConsoleCtrlEvent.CTRL_BREAK))
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS() || OperatingSystem.IsFreeBSD())
|
||||||
|
{
|
||||||
|
if (SendPosixSignal(PosixSignals.SIGINT))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (SendPosixSignal(PosixSignals.SIGTERM))
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsupported platform
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Kill()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_process.Kill(true);
|
||||||
|
}
|
||||||
|
catch when (_process.HasExited)
|
||||||
|
{
|
||||||
|
// The process has exited before we could kill it. This is fine.
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// The process either failed to exit or is in the process of exiting.
|
||||||
|
// We can't really do anything about it, so just ignore the exception.
|
||||||
|
Debug.Fail("Failed to kill the process.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task WaitUntilExitAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await _exitTcs.Task.WaitAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _process.Dispose();
|
||||||
|
}
|
7
src/Process/IO/BufferSizes.cs
Normal file
7
src/Process/IO/BufferSizes.cs
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
namespace Geekeey.Extensions.Process;
|
||||||
|
|
||||||
|
internal static class BufferSizes
|
||||||
|
{
|
||||||
|
public const int Stream = 81920;
|
||||||
|
public const int StreamReader = 1024;
|
||||||
|
}
|
112
src/Process/IO/MemoryBufferStream.cs
Normal file
112
src/Process/IO/MemoryBufferStream.cs
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
using System.Buffers;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace Geekeey.Extensions.Process;
|
||||||
|
|
||||||
|
internal sealed class MemoryBufferStream : Stream
|
||||||
|
{
|
||||||
|
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||||
|
private readonly SemaphoreSlim _readLock = new(0, 1);
|
||||||
|
|
||||||
|
private IMemoryOwner<byte> _sharedBuffer = MemoryPool<byte>.Shared.Rent(BufferSizes.Stream);
|
||||||
|
private int _sharedBufferBytes;
|
||||||
|
private int _sharedBufferBytesRead;
|
||||||
|
|
||||||
|
[ExcludeFromCodeCoverage] public override bool CanRead => true;
|
||||||
|
|
||||||
|
[ExcludeFromCodeCoverage] public override bool CanSeek => false;
|
||||||
|
|
||||||
|
[ExcludeFromCodeCoverage] public override bool CanWrite => true;
|
||||||
|
|
||||||
|
[ExcludeFromCodeCoverage] public override long Position { get; set; }
|
||||||
|
|
||||||
|
[ExcludeFromCodeCoverage] public override long Length => throw new NotSupportedException();
|
||||||
|
|
||||||
|
[ExcludeFromCodeCoverage]
|
||||||
|
public override void Write(byte[] buffer, int offset, int count)
|
||||||
|
=> WriteAsync(buffer, offset, count).GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
[ExcludeFromCodeCoverage]
|
||||||
|
public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
|
||||||
|
=> await WriteAsync(buffer.AsMemory(offset, count), cancellationToken);
|
||||||
|
|
||||||
|
public override async ValueTask WriteAsync(ReadOnlyMemory<byte> buffer,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await _writeLock.WaitAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Reset the buffer if the current one is too small for the incoming data
|
||||||
|
if (_sharedBuffer.Memory.Length < buffer.Length)
|
||||||
|
{
|
||||||
|
_sharedBuffer.Dispose();
|
||||||
|
_sharedBuffer = MemoryPool<byte>.Shared.Rent(buffer.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.CopyTo(_sharedBuffer.Memory);
|
||||||
|
|
||||||
|
_sharedBufferBytes = buffer.Length;
|
||||||
|
_sharedBufferBytesRead = 0;
|
||||||
|
|
||||||
|
_readLock.Release();
|
||||||
|
}
|
||||||
|
|
||||||
|
[ExcludeFromCodeCoverage]
|
||||||
|
public override int Read(byte[] buffer, int offset, int count)
|
||||||
|
=> ReadAsync(buffer, offset, count).GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
[ExcludeFromCodeCoverage]
|
||||||
|
public override async Task<int>
|
||||||
|
ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) =>
|
||||||
|
await ReadAsync(buffer.AsMemory(offset, count), cancellationToken);
|
||||||
|
|
||||||
|
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await _readLock.WaitAsync(cancellationToken);
|
||||||
|
|
||||||
|
var length = Math.Min(buffer.Length, _sharedBufferBytes - _sharedBufferBytesRead);
|
||||||
|
|
||||||
|
_sharedBuffer.Memory.Slice(_sharedBufferBytesRead, length).CopyTo(buffer);
|
||||||
|
|
||||||
|
_sharedBufferBytesRead += length;
|
||||||
|
|
||||||
|
// release the write lock if the consumer has finished reading all
|
||||||
|
// the previously written data.
|
||||||
|
if (_sharedBufferBytesRead >= _sharedBufferBytes)
|
||||||
|
{
|
||||||
|
_writeLock.Release();
|
||||||
|
}
|
||||||
|
// otherwise, release the read lock again so that the consumer can finish
|
||||||
|
// reading the data.
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_readLock.Release();
|
||||||
|
}
|
||||||
|
|
||||||
|
return length;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ReportCompletionAsync(CancellationToken cancellationToken = default) =>
|
||||||
|
// write an empty buffer that will make ReadAsync(...) return 0, which signals the end of stream
|
||||||
|
await WriteAsync(Memory<byte>.Empty, cancellationToken);
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing)
|
||||||
|
{
|
||||||
|
_readLock.Dispose();
|
||||||
|
_writeLock.Dispose();
|
||||||
|
_sharedBuffer.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
|
|
||||||
|
[ExcludeFromCodeCoverage]
|
||||||
|
public override void Flush() => throw new NotSupportedException();
|
||||||
|
|
||||||
|
[ExcludeFromCodeCoverage]
|
||||||
|
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
[ExcludeFromCodeCoverage]
|
||||||
|
public override void SetLength(long value) => throw new NotSupportedException();
|
||||||
|
}
|
102
src/Process/PipeSource.cs
Normal file
102
src/Process/PipeSource.cs
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Geekeey.Extensions.Process;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a pipe for the process's standard input stream.
|
||||||
|
/// </summary>
|
||||||
|
public abstract partial class PipeSource
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the binary content pushed into the pipe and writes it to the destination stream.
|
||||||
|
/// Destination stream represents the process's standard input stream.
|
||||||
|
/// </summary>
|
||||||
|
public abstract Task CopyToAsync(Stream destination, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class PipeSource
|
||||||
|
{
|
||||||
|
private class AnonymousPipeSource(Func<Stream, CancellationToken, Task> func) : PipeSource
|
||||||
|
{
|
||||||
|
public override async Task CopyToAsync(Stream destination, CancellationToken cancellationToken = default)
|
||||||
|
=> await func(destination, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class PipeSource
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Pipe source that does not provide any data.
|
||||||
|
/// Functionally equivalent to a null device.
|
||||||
|
/// </summary>
|
||||||
|
public static PipeSource Null { get; } = Create((_, cancellationToken)
|
||||||
|
=> !cancellationToken.IsCancellationRequested ? Task.CompletedTask : Task.FromCanceled(cancellationToken));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates an anonymous pipe source with the <see cref="CopyToAsync(Stream, CancellationToken)" /> method
|
||||||
|
/// implemented by the specified asynchronous delegate.
|
||||||
|
/// </summary>
|
||||||
|
public static PipeSource Create(Func<Stream, CancellationToken, Task> func)
|
||||||
|
=> new AnonymousPipeSource(func);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates an anonymous pipe source with the <see cref="CopyToAsync(Stream, CancellationToken)" /> method
|
||||||
|
/// implemented by the specified synchronous delegate.
|
||||||
|
/// </summary>
|
||||||
|
public static PipeSource Create(Action<Stream> action) => Create(
|
||||||
|
(destination, _) =>
|
||||||
|
{
|
||||||
|
action(destination);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a pipe source that reads from the specified stream.
|
||||||
|
/// </summary>
|
||||||
|
public static PipeSource FromStream(Stream stream) => Create(
|
||||||
|
async (destination, cancellationToken) =>
|
||||||
|
await stream.CopyToAsync(destination, cancellationToken));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a pipe source that reads from the specified file.
|
||||||
|
/// </summary>
|
||||||
|
public static PipeSource FromFile(string filePath) => Create(
|
||||||
|
async (destination, cancellationToken) =>
|
||||||
|
{
|
||||||
|
await using var source = File.OpenRead(filePath);
|
||||||
|
await source.CopyToAsync(destination, cancellationToken);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a pipe source that reads from the specified memory buffer.
|
||||||
|
/// </summary>
|
||||||
|
public static PipeSource FromBytes(ReadOnlyMemory<byte> data) => Create(
|
||||||
|
async (destination, cancellationToken) =>
|
||||||
|
await destination.WriteAsync(data, cancellationToken));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a pipe source that reads from the specified byte array.
|
||||||
|
/// </summary>
|
||||||
|
public static PipeSource FromBytes(byte[] data)
|
||||||
|
=> FromBytes((ReadOnlyMemory<byte>)data);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a pipe source that reads from the specified string.
|
||||||
|
/// </summary>
|
||||||
|
public static PipeSource FromString(string str, Encoding encoding)
|
||||||
|
=> FromBytes(encoding.GetBytes(str));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a pipe source that reads from the specified string.
|
||||||
|
/// Uses <see cref="Console.InputEncoding" /> for encoding.
|
||||||
|
/// </summary>
|
||||||
|
public static PipeSource FromString(string str)
|
||||||
|
=> FromString(str, Console.InputEncoding);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a pipe source that reads from the standard output of the specified command.
|
||||||
|
/// </summary>
|
||||||
|
public static PipeSource FromCommand(Command command) => Create(
|
||||||
|
async (destination, cancellationToken) =>
|
||||||
|
await command.WithStandardOutputPipe(PipeTarget.ToStream(destination)).ExecuteAsync(cancellationToken));
|
||||||
|
}
|
280
src/Process/PipeTarget.cs
Normal file
280
src/Process/PipeTarget.cs
Normal file
|
@ -0,0 +1,280 @@
|
||||||
|
using System.Buffers;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Geekeey.Extensions.Process;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a pipe for the process's standard output or standard error stream.
|
||||||
|
/// </summary>
|
||||||
|
public abstract partial class PipeTarget
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the binary content from the origin stream and pushes it into the pipe.
|
||||||
|
/// Origin stream represents the process's standard output or standard error stream.
|
||||||
|
/// </summary>
|
||||||
|
public abstract Task CopyFromAsync(Stream origin, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class PipeTarget
|
||||||
|
{
|
||||||
|
private class AnonymousPipeTarget(Func<Stream, CancellationToken, Task> func) : PipeTarget
|
||||||
|
{
|
||||||
|
public override async Task CopyFromAsync(Stream origin, CancellationToken cancellationToken = default)
|
||||||
|
=> await func(origin, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private class AggregatePipeTarget(IReadOnlyList<PipeTarget> targets) : PipeTarget
|
||||||
|
{
|
||||||
|
public IReadOnlyList<PipeTarget> Targets { get; } = targets;
|
||||||
|
|
||||||
|
public override async Task CopyFromAsync(Stream origin, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
|
||||||
|
// create a separate sub-stream for each target
|
||||||
|
var targetSubStreams = new Dictionary<PipeTarget, MemoryBufferStream>();
|
||||||
|
foreach (var target in Targets)
|
||||||
|
{
|
||||||
|
targetSubStreams[target] = new MemoryBufferStream();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// start piping in the background
|
||||||
|
async Task StartCopyAsync(KeyValuePair<PipeTarget, MemoryBufferStream> targetSubStream)
|
||||||
|
{
|
||||||
|
var (target, subStream) = targetSubStream;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// ReSharper disable once AccessToDisposedClosure
|
||||||
|
await target.CopyFromAsync(subStream, cts.Token);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// abort the operation if any of the targets fail
|
||||||
|
|
||||||
|
// ReSharper disable once AccessToDisposedClosure
|
||||||
|
await cts.CancelAsync();
|
||||||
|
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var readingTask = Task.WhenAll(targetSubStreams.Select(StartCopyAsync));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// read from the main stream and replicate the data to each sub-stream
|
||||||
|
using var buffer = MemoryPool<byte>.Shared.Rent(BufferSizes.Stream);
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var bytesRead = await origin.ReadAsync(buffer.Memory, cts.Token);
|
||||||
|
|
||||||
|
if (bytesRead <= 0)
|
||||||
|
break;
|
||||||
|
|
||||||
|
foreach (var (_, subStream) in targetSubStreams)
|
||||||
|
{
|
||||||
|
await subStream.WriteAsync(buffer.Memory[..bytesRead], cts.Token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// report that transmission is complete
|
||||||
|
foreach (var (_, subStream) in targetSubStreams)
|
||||||
|
{
|
||||||
|
await subStream.ReportCompletionAsync(cts.Token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// wait for all targets to finish and maybe propagate exceptions
|
||||||
|
await readingTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
foreach (var (_, stream) in targetSubStreams)
|
||||||
|
{
|
||||||
|
await stream.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class PipeTarget
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Pipe target that discards all data. Functionally equivalent to a null device.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Using this target results in the corresponding stream (standard output or standard error) not being opened for
|
||||||
|
/// the underlying process at all. In the vast majority of cases, this behavior should be functionally equivalent to
|
||||||
|
/// piping to a null stream, but without the performance overhead of consuming and discarding unneeded data. This
|
||||||
|
/// may be undesirable in certain situations, in which case it's recommended to pipe to a null stream explicitly
|
||||||
|
/// using <see cref="ToStream(Stream)" /> with <see cref="Stream.Null" />.
|
||||||
|
/// </remarks>
|
||||||
|
public static PipeTarget Null { get; } = Create((_, cancellationToken) =>
|
||||||
|
!cancellationToken.IsCancellationRequested ? Task.CompletedTask : Task.FromCanceled(cancellationToken));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates an anonymous pipe target with the <see cref="CopyFromAsync(Stream, CancellationToken)" /> method
|
||||||
|
/// implemented by the specified asynchronous delegate.
|
||||||
|
/// </summary>
|
||||||
|
public static PipeTarget Create(Func<Stream, CancellationToken, Task> func)
|
||||||
|
=> new AnonymousPipeTarget(func);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates an anonymous pipe target with the <see cref="CopyFromAsync(Stream, CancellationToken)" /> method
|
||||||
|
/// implemented by the specified synchronous delegate.
|
||||||
|
/// </summary>
|
||||||
|
public static PipeTarget Create(Action<Stream> action) => Create(
|
||||||
|
(origin, _) =>
|
||||||
|
{
|
||||||
|
action(origin);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a pipe target that writes to the specified stream.
|
||||||
|
/// </summary>
|
||||||
|
public static PipeTarget ToStream(Stream stream) => Create(
|
||||||
|
async (origin, cancellationToken) =>
|
||||||
|
await origin.CopyToAsync(stream, cancellationToken));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a pipe target that writes to the specified file.
|
||||||
|
/// </summary>
|
||||||
|
public static PipeTarget ToFile(string filePath) => Create(
|
||||||
|
async (origin, cancellationToken) =>
|
||||||
|
{
|
||||||
|
await using var target = File.Create(filePath);
|
||||||
|
await origin.CopyToAsync(target, cancellationToken);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a pipe target that writes to the specified string builder.
|
||||||
|
/// </summary>
|
||||||
|
public static PipeTarget ToStringBuilder(StringBuilder stringBuilder, Encoding encoding) => Create(
|
||||||
|
async (origin, cancellationToken) =>
|
||||||
|
{
|
||||||
|
using var reader = new StreamReader(origin, encoding, false, BufferSizes.StreamReader, true);
|
||||||
|
using var buffer = MemoryPool<char>.Shared.Rent(BufferSizes.StreamReader);
|
||||||
|
|
||||||
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
var charsRead = await reader.ReadAsync(buffer.Memory, cancellationToken);
|
||||||
|
if (charsRead <= 0) break;
|
||||||
|
stringBuilder.Append(buffer.Memory[..charsRead]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a pipe target that writes to the specified string builder.
|
||||||
|
/// Uses <see cref="Console.OutputEncoding" /> for decoding.
|
||||||
|
/// </summary>
|
||||||
|
public static PipeTarget ToStringBuilder(StringBuilder stringBuilder)
|
||||||
|
=> ToStringBuilder(stringBuilder, Console.OutputEncoding);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a pipe target that invokes the specified asynchronous delegate on every line written to the stream.
|
||||||
|
/// </summary>
|
||||||
|
public static PipeTarget ToDelegate(Func<string, CancellationToken, Task> func, Encoding encoding) => Create(
|
||||||
|
async (origin, cancellationToken) =>
|
||||||
|
{
|
||||||
|
using var reader = new StreamReader(origin, encoding, false, BufferSizes.StreamReader, true);
|
||||||
|
while (await reader.ReadLineAsync(cancellationToken) is { } line)
|
||||||
|
{
|
||||||
|
await func(line, cancellationToken);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a pipe target that invokes the specified asynchronous delegate on every line written to the stream.
|
||||||
|
/// Uses <see cref="Console.OutputEncoding" /> for decoding.
|
||||||
|
/// </summary>
|
||||||
|
public static PipeTarget ToDelegate(Func<string, CancellationToken, Task> func) =>
|
||||||
|
ToDelegate(func, Console.OutputEncoding);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a pipe target that invokes the specified asynchronous delegate on every line written to the stream.
|
||||||
|
/// </summary>
|
||||||
|
public static PipeTarget ToDelegate(Func<string, Task> func, Encoding encoding) => ToDelegate(
|
||||||
|
async (line, _) => await func(line), encoding);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a pipe target that invokes the specified asynchronous delegate on every line written to the stream.
|
||||||
|
/// Uses <see cref="Console.OutputEncoding" /> for decoding.
|
||||||
|
/// </summary>
|
||||||
|
public static PipeTarget ToDelegate(Func<string, Task> func) => ToDelegate(func, Console.OutputEncoding);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a pipe target that invokes the specified synchronous delegate on every line written to the stream.
|
||||||
|
/// </summary>
|
||||||
|
public static PipeTarget ToDelegate(Action<string> action, Encoding encoding) => ToDelegate(
|
||||||
|
line =>
|
||||||
|
{
|
||||||
|
action(line);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}, encoding);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a pipe target that invokes the specified synchronous delegate on every line written to the stream.
|
||||||
|
/// Uses <see cref="Console.OutputEncoding" /> for decoding.
|
||||||
|
/// </summary>
|
||||||
|
public static PipeTarget ToDelegate(Action<string> action)
|
||||||
|
=> ToDelegate(action, Console.OutputEncoding);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a pipe target that replicates data over multiple inner targets.
|
||||||
|
/// </summary>
|
||||||
|
public static PipeTarget Merge(IEnumerable<PipeTarget> targets)
|
||||||
|
{
|
||||||
|
// optimize targets to avoid unnecessary piping
|
||||||
|
var optimizedTargets = OptimizeTargets(targets);
|
||||||
|
|
||||||
|
return optimizedTargets.Count switch
|
||||||
|
{
|
||||||
|
// avoid merging if there are no targets
|
||||||
|
0 => Null,
|
||||||
|
// avoid merging if there's only one target
|
||||||
|
1 => optimizedTargets.Single(),
|
||||||
|
_ => new AggregatePipeTarget(optimizedTargets)
|
||||||
|
};
|
||||||
|
|
||||||
|
static IReadOnlyList<PipeTarget> OptimizeTargets(IEnumerable<PipeTarget> targets)
|
||||||
|
{
|
||||||
|
var result = new List<PipeTarget>();
|
||||||
|
|
||||||
|
// unwrap merged targets
|
||||||
|
UnwrapTargets(targets, result);
|
||||||
|
|
||||||
|
// filter out no-op
|
||||||
|
result.RemoveAll(t => t == Null);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void UnwrapTargets(IEnumerable<PipeTarget> targets, ICollection<PipeTarget> output)
|
||||||
|
{
|
||||||
|
foreach (var target in targets)
|
||||||
|
{
|
||||||
|
if (target is AggregatePipeTarget mergedTarget)
|
||||||
|
{
|
||||||
|
UnwrapTargets(mergedTarget.Targets, output);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
output.Add(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a pipe target that replicates data over multiple inner targets.
|
||||||
|
/// </summary>
|
||||||
|
public static PipeTarget Merge(params PipeTarget[] targets) => Merge((IEnumerable<PipeTarget>)targets);
|
||||||
|
}
|
18
src/Process/Prelude.cs
Normal file
18
src/Process/Prelude.cs
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
namespace Geekeey.Extensions.Process;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A class containing various utility methods, a 'prelude' to the rest of the library.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This class is meant to be imported statically, e.g. <c>using static Geekeey.Extensions.Process.Prelude;</c>.
|
||||||
|
/// Recommended to be imported globally via a global using statement.
|
||||||
|
/// </remarks>
|
||||||
|
public static class Prelude
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new command that targets the specified command-line executable, batch file, or script.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="targetFilePath">The name of the file to be executed.</param>
|
||||||
|
/// <returns>A command representing the parameters used to execute the executable.</returns>
|
||||||
|
public static Command Run(string targetFilePath) => new(targetFilePath);
|
||||||
|
}
|
19
src/Process/Process.csproj
Normal file
19
src/Process/Process.csproj
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<!-- required because of native library import for libc -->
|
||||||
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="$(AssemblyName).Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include=".\package-readme.md" Pack="true" PackagePath="\" />
|
||||||
|
<None Include="Project.props" Pack="true" PackagePath="build\$(AssemblyName).props" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
5
src/Process/Project.props
Normal file
5
src/Process/Project.props
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
<Project>
|
||||||
|
<ItemGroup Condition="'$(ImplicitUsings)' == 'enable'">
|
||||||
|
<Using Include="Geekeey.Extensions.Process.Prelude" Static="true" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
2
src/Process/package-readme.md
Normal file
2
src/Process/package-readme.md
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
Process is a library for interacting with external command-line interfaces. It provides a convenient model for launching
|
||||||
|
processes, redirecting input and output streams, awaiting completion, handling cancellation, and more.
|
Loading…
Reference in a new issue