From 5a825ffab23f449852d2585a3e9d735302a534fc Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Sat, 27 Apr 2024 20:15:05 +0200 Subject: [PATCH] feat: initial project commit --- .editorconfig | 447 ++++++++++++++++ .forgejo/workflows/README.md | 15 + .forgejo/workflows/default.yml | 37 ++ .forgejo/workflows/release.yml | 24 + .gitignore | 478 ++++++++++++++++++ Directory.Build.props | 27 + Directory.Build.targets | 3 + Directory.Packages.props | 15 + Geekeey.Extensions.Process.sln | 36 ++ global.json | 9 + nuget.config | 19 + .../Commands/EchoCommand.cs | 25 + .../Commands/EchoStdinCommand.cs | 39 ++ .../Commands/EnvironmentCommand.cs | 21 + .../Commands/ExitCommand.cs | 17 + .../Commands/WorkingDirectoryCommand.cs | 16 + src/Process.Tests.Dummy/OutputTarget.cs | 21 + .../Process.Tests.Dummy.csproj | 10 + src/Process.Tests.Dummy/Program.cs | 36 ++ src/Process.Tests/BufferedExecuteTests.cs | 68 +++ src/Process.Tests/CommandTests.cs | 271 ++++++++++ src/Process.Tests/ExecuteTests.cs | 139 +++++ src/Process.Tests/Fixtures/TestEnvironment.cs | 19 + .../Fixtures/TestTempDirectory.cs | 28 + src/Process.Tests/Fixtures/TestTempFile.cs | 30 ++ src/Process.Tests/PathResolutionTests.cs | 48 ++ src/Process.Tests/PipingTests.cs | 127 +++++ src/Process.Tests/Process.Tests.csproj | 23 + src/Process.Tests/ValidationTests.cs | 60 +++ .../Buffered/BufferedCommandExtensions.cs | 95 ++++ src/Process/Buffered/BufferedCommandResult.cs | 23 + src/Process/Builders/ArgumentsBuilder.cs | 137 +++++ .../Builders/EnvironmentVariablesBuilder.cs | 43 ++ src/Process/Command.Builder.cs | 113 +++++ src/Process/Command.Execute.cs | 212 ++++++++ src/Process/Command.Piping.cs | 145 ++++++ src/Process/Command.cs | 65 +++ src/Process/CommandExecutionException.cs | 23 + src/Process/CommandExitBehaviour.cs | 18 + src/Process/CommandResult.cs | 35 ++ src/Process/CommandTask.cs | 58 +++ src/Process/Execution/ProcessHandle.Posix.cs | 30 ++ .../Execution/ProcessHandle.Windows.cs | 19 + src/Process/Execution/ProcessHandle.cs | 113 +++++ src/Process/IO/BufferSizes.cs | 7 + src/Process/IO/MemoryBufferStream.cs | 112 ++++ src/Process/PipeSource.cs | 102 ++++ src/Process/PipeTarget.cs | 280 ++++++++++ src/Process/Prelude.cs | 18 + src/Process/Process.csproj | 19 + src/Process/Project.props | 5 + src/Process/package-readme.md | 0 52 files changed, 3780 insertions(+) create mode 100644 .editorconfig create mode 100644 .forgejo/workflows/README.md create mode 100644 .forgejo/workflows/default.yml create mode 100644 .forgejo/workflows/release.yml create mode 100644 .gitignore create mode 100644 Directory.Build.props create mode 100644 Directory.Build.targets create mode 100644 Directory.Packages.props create mode 100644 Geekeey.Extensions.Process.sln create mode 100644 global.json create mode 100644 nuget.config create mode 100644 src/Process.Tests.Dummy/Commands/EchoCommand.cs create mode 100644 src/Process.Tests.Dummy/Commands/EchoStdinCommand.cs create mode 100644 src/Process.Tests.Dummy/Commands/EnvironmentCommand.cs create mode 100644 src/Process.Tests.Dummy/Commands/ExitCommand.cs create mode 100644 src/Process.Tests.Dummy/Commands/WorkingDirectoryCommand.cs create mode 100644 src/Process.Tests.Dummy/OutputTarget.cs create mode 100644 src/Process.Tests.Dummy/Process.Tests.Dummy.csproj create mode 100644 src/Process.Tests.Dummy/Program.cs create mode 100644 src/Process.Tests/BufferedExecuteTests.cs create mode 100644 src/Process.Tests/CommandTests.cs create mode 100644 src/Process.Tests/ExecuteTests.cs create mode 100644 src/Process.Tests/Fixtures/TestEnvironment.cs create mode 100644 src/Process.Tests/Fixtures/TestTempDirectory.cs create mode 100644 src/Process.Tests/Fixtures/TestTempFile.cs create mode 100644 src/Process.Tests/PathResolutionTests.cs create mode 100644 src/Process.Tests/PipingTests.cs create mode 100644 src/Process.Tests/Process.Tests.csproj create mode 100644 src/Process.Tests/ValidationTests.cs create mode 100644 src/Process/Buffered/BufferedCommandExtensions.cs create mode 100644 src/Process/Buffered/BufferedCommandResult.cs create mode 100644 src/Process/Builders/ArgumentsBuilder.cs create mode 100644 src/Process/Builders/EnvironmentVariablesBuilder.cs create mode 100644 src/Process/Command.Builder.cs create mode 100644 src/Process/Command.Execute.cs create mode 100644 src/Process/Command.Piping.cs create mode 100644 src/Process/Command.cs create mode 100644 src/Process/CommandExecutionException.cs create mode 100644 src/Process/CommandExitBehaviour.cs create mode 100644 src/Process/CommandResult.cs create mode 100644 src/Process/CommandTask.cs create mode 100644 src/Process/Execution/ProcessHandle.Posix.cs create mode 100644 src/Process/Execution/ProcessHandle.Windows.cs create mode 100644 src/Process/Execution/ProcessHandle.cs create mode 100644 src/Process/IO/BufferSizes.cs create mode 100644 src/Process/IO/MemoryBufferStream.cs create mode 100644 src/Process/PipeSource.cs create mode 100644 src/Process/PipeTarget.cs create mode 100644 src/Process/Prelude.cs create mode 100644 src/Process/Process.csproj create mode 100644 src/Process/Project.props create mode 100644 src/Process/package-readme.md diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..e3eaae3 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.forgejo/workflows/README.md b/.forgejo/workflows/README.md new file mode 100644 index 0000000..569670f --- /dev/null +++ b/.forgejo/workflows/README.md @@ -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. \ No newline at end of file diff --git a/.forgejo/workflows/default.yml b/.forgejo/workflows/default.yml new file mode 100644 index 0000000..af18f95 --- /dev/null +++ b/.forgejo/workflows/default.yml @@ -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 \ No newline at end of file diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml new file mode 100644 index 0000000..4d63d70 --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4ee88cf --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..5cfa76d --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,27 @@ + + + + true + + + + net8.0 + enable + enable + + 1.0.0 + + + + + Geekeey.Extensions.$(MSBuildProjectName) + Geekeey.Extensions.$(MSBuildProjectName) + + + + The Geekeey Team + A simple and lightweight way to interact with other processes from C#. + geekeey utility process + package-readme.md + + diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..0c98d16 --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..4d6906b --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,15 @@ + + + + true + + + + + + + + + + + \ No newline at end of file diff --git a/Geekeey.Extensions.Process.sln b/Geekeey.Extensions.Process.sln new file mode 100644 index 0000000..ea688bf --- /dev/null +++ b/Geekeey.Extensions.Process.sln @@ -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 diff --git a/global.json b/global.json new file mode 100644 index 0000000..6a12de5 --- /dev/null +++ b/global.json @@ -0,0 +1,9 @@ +{ + "sdk": { + "version": "8.0.0", + "rollForward": "latestMajor", + "allowPrerelease": true + }, + "msbuild-sdks": { + } +} \ No newline at end of file diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000..8ceffa3 --- /dev/null +++ b/nuget.config @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Process.Tests.Dummy/Commands/EchoCommand.cs b/src/Process.Tests.Dummy/Commands/EchoCommand.cs new file mode 100644 index 0000000..2e9f8ed --- /dev/null +++ b/src/Process.Tests.Dummy/Commands/EchoCommand.cs @@ -0,0 +1,25 @@ +using Spectre.Console.Cli; + +namespace Geekeey.Extensions.Process.Tests.Dummy.Commands; + +internal sealed class EchoCommand : Command +{ + public sealed class Settings : CommandSettings + { + [CommandOption("--target")] public OutputTarget Target { get; init; } = OutputTarget.StdOut; + + [CommandOption("--separator ")] 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; + } +} \ No newline at end of file diff --git a/src/Process.Tests.Dummy/Commands/EchoStdinCommand.cs b/src/Process.Tests.Dummy/Commands/EchoStdinCommand.cs new file mode 100644 index 0000000..af6aa4d --- /dev/null +++ b/src/Process.Tests.Dummy/Commands/EchoStdinCommand.cs @@ -0,0 +1,39 @@ +using System.Buffers; + +using Spectre.Console.Cli; + +namespace Geekeey.Extensions.Process.Tests.Dummy.Commands; + +internal sealed class EchoStdinCommand : Command +{ + 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.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; + } +} \ No newline at end of file diff --git a/src/Process.Tests.Dummy/Commands/EnvironmentCommand.cs b/src/Process.Tests.Dummy/Commands/EnvironmentCommand.cs new file mode 100644 index 0000000..6ab7d15 --- /dev/null +++ b/src/Process.Tests.Dummy/Commands/EnvironmentCommand.cs @@ -0,0 +1,21 @@ +using Spectre.Console.Cli; + +namespace Geekeey.Extensions.Process.Tests.Dummy.Commands; + +internal sealed class EnvironmentCommand : Command +{ + public sealed class Settings : CommandSettings + { + [CommandArgument(0, "")] 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; + } +} \ No newline at end of file diff --git a/src/Process.Tests.Dummy/Commands/ExitCommand.cs b/src/Process.Tests.Dummy/Commands/ExitCommand.cs new file mode 100644 index 0000000..889aa3c --- /dev/null +++ b/src/Process.Tests.Dummy/Commands/ExitCommand.cs @@ -0,0 +1,17 @@ +using Spectre.Console.Cli; + +namespace Geekeey.Extensions.Process.Tests.Dummy.Commands; + +internal sealed class ExitCommand : Command +{ + public sealed class Settings : CommandSettings + { + [CommandArgument(1, "")] 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; + } +} \ No newline at end of file diff --git a/src/Process.Tests.Dummy/Commands/WorkingDirectoryCommand.cs b/src/Process.Tests.Dummy/Commands/WorkingDirectoryCommand.cs new file mode 100644 index 0000000..c34bfbf --- /dev/null +++ b/src/Process.Tests.Dummy/Commands/WorkingDirectoryCommand.cs @@ -0,0 +1,16 @@ +using Spectre.Console.Cli; + +namespace Geekeey.Extensions.Process.Tests.Dummy.Commands; + +internal sealed class WorkingDirectoryCommand : Command +{ + public sealed class Settings : CommandSettings + { + } + + public override int Execute(CommandContext context, Settings settings) + { + Console.Out.WriteLine(Directory.GetCurrentDirectory()); + return 0; + } +} \ No newline at end of file diff --git a/src/Process.Tests.Dummy/OutputTarget.cs b/src/Process.Tests.Dummy/OutputTarget.cs new file mode 100644 index 0000000..6f28ac1 --- /dev/null +++ b/src/Process.Tests.Dummy/OutputTarget.cs @@ -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 GetWriters(this OutputTarget target) + { + if (target.HasFlag(OutputTarget.StdOut)) + yield return Console.Out; + + if (target.HasFlag(OutputTarget.StdErr)) + yield return Console.Error; + } +} \ No newline at end of file diff --git a/src/Process.Tests.Dummy/Process.Tests.Dummy.csproj b/src/Process.Tests.Dummy/Process.Tests.Dummy.csproj new file mode 100644 index 0000000..7530c9a --- /dev/null +++ b/src/Process.Tests.Dummy/Process.Tests.Dummy.csproj @@ -0,0 +1,10 @@ + + + Exe + + + + + + + diff --git a/src/Process.Tests.Dummy/Program.cs b/src/Process.Tests.Dummy/Program.cs new file mode 100644 index 0000000..06cd364 --- /dev/null +++ b/src/Process.Tests.Dummy/Program.cs @@ -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 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("echo"); + configuration.AddCommand("echo-stdin"); + configuration.AddCommand("env"); + configuration.AddCommand("cwd"); + configuration.AddCommand("exit"); + } + } +} \ No newline at end of file diff --git a/src/Process.Tests/BufferedExecuteTests.cs b/src/Process.Tests/BufferedExecuteTests.cs new file mode 100644 index 0000000..d8e01e5 --- /dev/null +++ b/src/Process.Tests/BufferedExecuteTests.cs @@ -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")); + }); + } +} \ No newline at end of file diff --git a/src/Process.Tests/CommandTests.cs b/src/Process.Tests/CommandTests.cs new file mode 100644 index 0000000..fa45779 --- /dev/null +++ b/src/Process.Tests/CommandTests.cs @@ -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 { ["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 { ["zzz"] = "yyy", ["aaa"] = "bbb" })); + + Assert.Multiple(() => + { + var vars = new Dictionary + { + ["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)); + }); + } +} \ No newline at end of file diff --git a/src/Process.Tests/ExecuteTests.cs b/src/Process.Tests/ExecuteTests.cs new file mode 100644 index 0000000..1cbdfbe --- /dev/null +++ b/src/Process.Tests/ExecuteTests.cs @@ -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(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" })); + } +} \ No newline at end of file diff --git a/src/Process.Tests/Fixtures/TestEnvironment.cs b/src/Process.Tests/Fixtures/TestEnvironment.cs new file mode 100644 index 0000000..4715c8f --- /dev/null +++ b/src/Process.Tests/Fixtures/TestEnvironment.cs @@ -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(); +} \ No newline at end of file diff --git a/src/Process.Tests/Fixtures/TestTempDirectory.cs b/src/Process.Tests/Fixtures/TestTempDirectory.cs new file mode 100644 index 0000000..67027c5 --- /dev/null +++ b/src/Process.Tests/Fixtures/TestTempDirectory.cs @@ -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) { } + } +} \ No newline at end of file diff --git a/src/Process.Tests/Fixtures/TestTempFile.cs b/src/Process.Tests/Fixtures/TestTempFile.cs new file mode 100644 index 0000000..b899baa --- /dev/null +++ b/src/Process.Tests/Fixtures/TestTempFile.cs @@ -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) { } + } +} \ No newline at end of file diff --git a/src/Process.Tests/PathResolutionTests.cs b/src/Process.Tests/PathResolutionTests.cs new file mode 100644 index 0000000..7be9920 --- /dev/null +++ b/src/Process.Tests/PathResolutionTests.cs @@ -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")); + }); + } +} \ No newline at end of file diff --git a/src/Process.Tests/PipingTests.cs b/src/Process.Tests/PipingTests.cs new file mode 100644 index 0000000..3981126 --- /dev/null +++ b/src/Process.Tests/PipingTests.cs @@ -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("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() | new Command(Dummy.Program.FilePath) + .WithArguments("echo-stdin"); + + // Act + await cmd.ExecuteAsync(); + } +} \ No newline at end of file diff --git a/src/Process.Tests/Process.Tests.csproj b/src/Process.Tests/Process.Tests.csproj new file mode 100644 index 0000000..60be8bf --- /dev/null +++ b/src/Process.Tests/Process.Tests.csproj @@ -0,0 +1,23 @@ + + + false + true + + + + + + + + + + + + + + + + + + + diff --git a/src/Process.Tests/ValidationTests.cs b/src/Process.Tests/ValidationTests.cs new file mode 100644 index 0000000..33ed944 --- /dev/null +++ b/src/Process.Tests/ValidationTests.cs @@ -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(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(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); + }); + } +} \ No newline at end of file diff --git a/src/Process/Buffered/BufferedCommandExtensions.cs b/src/Process/Buffered/BufferedCommandExtensions.cs new file mode 100644 index 0000000..35603c4 --- /dev/null +++ b/src/Process/Buffered/BufferedCommandExtensions.cs @@ -0,0 +1,95 @@ +using System.Text; + +namespace Geekeey.Extensions.Process.Buffered; + +/// +/// Buffered execution model. +/// +public static class BufferedCommandExtensions +{ + /// + /// 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. + /// + /// + /// This method can be awaited. + /// + public static CommandTask 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); + } + }); + } + + /// + /// 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. + /// + /// + /// This method can be awaited. + /// + public static CommandTask ExecuteBufferedAsync(this Command command, + Encoding encoding, CancellationToken cancellationToken = default) + { + return command.ExecuteBufferedAsync(encoding, encoding, cancellationToken); + } + + /// + /// 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 for decoding. + /// + /// + /// This method can be awaited. + /// + public static CommandTask ExecuteBufferedAsync(this Command command, + CancellationToken cancellationToken = default) + { + return command.ExecuteBufferedAsync(Console.OutputEncoding, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Process/Buffered/BufferedCommandResult.cs b/src/Process/Buffered/BufferedCommandResult.cs new file mode 100644 index 0000000..554afb7 --- /dev/null +++ b/src/Process/Buffered/BufferedCommandResult.cs @@ -0,0 +1,23 @@ +namespace Geekeey.Extensions.Process.Buffered; + +/// +/// Result of a command execution, with buffered text data from standard output and standard error streams. +/// +public class BufferedCommandResult( + int exitCode, + DateTimeOffset startTime, + DateTimeOffset exitTime, + string standardOutput, + string standardError +) : CommandResult(exitCode, startTime, exitTime) +{ + /// + /// Standard output data produced by the underlying process. + /// + public string StandardOutput { get; } = standardOutput; + + /// + /// Standard error data produced by the underlying process. + /// + public string StandardError { get; } = standardError; +} \ No newline at end of file diff --git a/src/Process/Builders/ArgumentsBuilder.cs b/src/Process/Builders/ArgumentsBuilder.cs new file mode 100644 index 0000000..124413f --- /dev/null +++ b/src/Process/Builders/ArgumentsBuilder.cs @@ -0,0 +1,137 @@ +using System.Globalization; +using System.Text; + +namespace Geekeey.Extensions.Process; + +/// +/// Builder that helps format command-line arguments into a string. +/// +public sealed partial class ArgumentsBuilder +{ + private static readonly IFormatProvider DefaultFormatProvider = CultureInfo.InvariantCulture; + + private readonly StringBuilder _buffer = new(); + + /// + /// Adds the specified value to the list of arguments. + /// + public ArgumentsBuilder Add(string value, bool escape = true) + { + if (_buffer.Length > 0) + _buffer.Append(' '); + + _buffer.Append(escape ? Escape(value) : value); + + return this; + } + + /// + /// Adds the specified values to the list of arguments. + /// + public ArgumentsBuilder Add(IEnumerable values, bool escape = true) + { + foreach (var value in values) + { + Add(value, escape); + } + + return this; + } + + /// + /// Adds the specified value to the list of arguments. + /// + public ArgumentsBuilder Add(IFormattable value, IFormatProvider formatProvider, bool escape = true) + => Add(value.ToString(null, formatProvider), escape); + + /// + /// Adds the specified value to the list of arguments. + /// The value is converted to string using invariant culture. + /// + public ArgumentsBuilder Add(IFormattable value, bool escape = true) + => Add(value, DefaultFormatProvider, escape); + + /// + /// Adds the specified values to the list of arguments. + /// + public ArgumentsBuilder Add(IEnumerable values, IFormatProvider formatProvider, bool escape = true) + { + foreach (var value in values) + { + Add(value, formatProvider, escape); + } + + return this; + } + + /// + /// Adds the specified values to the list of arguments. + /// The values are converted to string using invariant culture. + /// + public ArgumentsBuilder Add(IEnumerable values, bool escape = true) + => Add(values, DefaultFormatProvider, escape); + + /// + /// Builds the resulting arguments string. + /// + 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(); + } +} \ No newline at end of file diff --git a/src/Process/Builders/EnvironmentVariablesBuilder.cs b/src/Process/Builders/EnvironmentVariablesBuilder.cs new file mode 100644 index 0000000..e7c2147 --- /dev/null +++ b/src/Process/Builders/EnvironmentVariablesBuilder.cs @@ -0,0 +1,43 @@ +namespace Geekeey.Extensions.Process; + +/// +/// Builder that helps configure environment variables. +/// +public sealed class EnvironmentVariablesBuilder +{ + private readonly Dictionary _vars = new(StringComparer.Ordinal); + + /// + /// Sets an environment variable with the specified name to the specified value. + /// + public EnvironmentVariablesBuilder Set(string name, string? value) + { + _vars[name] = value; + return this; + } + + /// + /// Sets multiple environment variables from the specified sequence of key-value pairs. + /// + public EnvironmentVariablesBuilder Set(IEnumerable> variables) + { + foreach (var (name, value) in variables) + { + Set(name, value); + } + + return this; + } + + /// + /// Sets multiple environment variables from the specified dictionary. + /// + public EnvironmentVariablesBuilder Set(IReadOnlyDictionary variables) + => Set((IEnumerable>)variables); + + /// + /// Builds the resulting environment variables. + /// + public IReadOnlyDictionary Build() + => new Dictionary(_vars, _vars.Comparer); +} \ No newline at end of file diff --git a/src/Process/Command.Builder.cs b/src/Process/Command.Builder.cs new file mode 100644 index 0000000..3d73a9e --- /dev/null +++ b/src/Process/Command.Builder.cs @@ -0,0 +1,113 @@ +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Contracts; + +namespace Geekeey.Extensions.Process; + +public sealed partial class Command +{ + /// + /// Creates a copy of this command, setting the target file path to the specified value. + /// + [Pure] + public Command WithTargetFile(string targetFilePath) + => new(targetFilePath, Arguments, WorkingDirPath, EnvironmentVariables, Validation, + StandardInputPipe, StandardOutputPipe, StandardErrorPipe); + + /// + /// Creates a copy of this command, setting the arguments to the specified value. + /// + /// + /// Avoid using this overload, as it requires the arguments to be escaped manually. + /// Formatting errors may lead to unexpected bugs and security vulnerabilities. + /// + [Pure] + public Command WithArguments(string arguments) + => new(TargetFilePath, arguments, WorkingDirPath, EnvironmentVariables, Validation, + StandardInputPipe, StandardOutputPipe, StandardErrorPipe); + + /// + /// Creates a copy of this command, setting the arguments to the value + /// obtained by formatting the specified enumeration. + /// + [Pure] + public Command WithArguments(IEnumerable arguments, bool escape = true) + => WithArguments(args => args.Add(arguments, escape)); + + /// + /// Creates a copy of this command, setting the arguments to the value + /// configured by the specified delegate. + /// + [Pure] + public Command WithArguments(Action configure) + { + var builder = new ArgumentsBuilder(); + configure(builder); + + return WithArguments(builder.Build()); + } + + /// + /// Creates a copy of this command, setting the working directory path to the specified value. + /// + [Pure] + public Command WithWorkingDirectory(string workingDirPath) + => new(TargetFilePath, Arguments, workingDirPath, EnvironmentVariables, Validation, + StandardInputPipe, StandardOutputPipe, StandardErrorPipe); + + /// + /// Creates a copy of this command, setting the environment variables to the specified value. + /// + [Pure] + public Command WithEnvironmentVariables(IReadOnlyDictionary environmentVariables) + => new(TargetFilePath, Arguments, WorkingDirPath, environmentVariables, Validation, + StandardInputPipe, StandardOutputPipe, StandardErrorPipe); + + /// + /// Creates a copy of this command, setting the environment variables to the value + /// configured by the specified delegate. + /// + [Pure] + public Command WithEnvironmentVariables(Action configure) + { + var builder = new EnvironmentVariablesBuilder(); + configure(builder); + + return WithEnvironmentVariables(builder.Build()); + } + + /// + /// Creates a copy of this command, setting the validation options to the specified value. + /// + [Pure] + public Command WithValidation(CommandExitBehaviour exitBehaviour) + => new(TargetFilePath, Arguments, WorkingDirPath, EnvironmentVariables, exitBehaviour, + StandardInputPipe, StandardOutputPipe, StandardErrorPipe); + + /// + /// Creates a copy of this command, setting the standard input pipe to the specified source. + /// + [Pure] + public Command WithStandardInputPipe(PipeSource source) + => new(TargetFilePath, Arguments, WorkingDirPath, EnvironmentVariables, Validation, + source, StandardOutputPipe, StandardErrorPipe); + + /// + /// Creates a copy of this command, setting the standard output pipe to the specified target. + /// + [Pure] + public Command WithStandardOutputPipe(PipeTarget target) + => new(TargetFilePath, Arguments, WorkingDirPath, EnvironmentVariables, Validation, + StandardInputPipe, target, StandardErrorPipe); + + /// + /// Creates a copy of this command, setting the standard error pipe to the specified target. + /// + [Pure] + public Command WithStandardErrorPipe(PipeTarget target) + => new(TargetFilePath, Arguments, WorkingDirPath, EnvironmentVariables, Validation, + StandardInputPipe, StandardOutputPipe, target); + + /// + [ExcludeFromCodeCoverage] + public override string ToString() => $"{TargetFilePath} {Arguments}"; +} \ No newline at end of file diff --git a/src/Process/Command.Execute.cs b/src/Process/Command.Execute.cs new file mode 100644 index 0000000..50d8448 --- /dev/null +++ b/src/Process/Command.Execute.cs @@ -0,0 +1,212 @@ +using System.Diagnostics; + +namespace Geekeey.Extensions.Process; + +public partial class Command +{ + private static readonly Lazy 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 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; + } + } + } + } + } + + /// + /// Executes the command asynchronously. + /// + /// + /// This method can be awaited. + /// + public CommandTask 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(task, processId); + } + + private async Task 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. + } + } + } +} \ No newline at end of file diff --git a/src/Process/Command.Piping.cs b/src/Process/Command.Piping.cs new file mode 100644 index 0000000..9ee996d --- /dev/null +++ b/src/Process/Command.Piping.cs @@ -0,0 +1,145 @@ +using System.Diagnostics.Contracts; +using System.Text; + +namespace Geekeey.Extensions.Process; + +public partial class Command +{ + /// + /// Creates a new command that pipes its standard output to the specified target. + /// + [Pure] + public static Command operator |(Command source, PipeTarget target) + => source.WithStandardOutputPipe(target); + + /// + /// Creates a new command that pipes its standard output to the specified string builder. + /// Uses for decoding. + /// + [Pure] + public static Command operator |(Command source, StringBuilder target) + => source | PipeTarget.ToStringBuilder(target, Console.OutputEncoding); + + /// + /// Creates a new command that pipes its standard output line-by-line to the specified + /// asynchronous delegate. + /// Uses for decoding. + /// + [Pure] + public static Command operator |(Command source, Func target) + => source | PipeTarget.ToDelegate(target, Console.OutputEncoding); + + /// + /// Creates a new command that pipes its standard output line-by-line to the specified + /// asynchronous delegate. + /// Uses for decoding. + /// + [Pure] + public static Command operator |(Command source, Func target) + => source | PipeTarget.ToDelegate(target, Console.OutputEncoding); + + /// + /// Creates a new command that pipes its standard output line-by-line to the specified + /// synchronous delegate. + /// Uses for decoding. + /// + [Pure] + public static Command operator |(Command source, Action target) + => source | PipeTarget.ToDelegate(target, Console.OutputEncoding); + + /// + /// Creates a new command that pipes its standard output and standard error to the + /// specified targets. + /// + [Pure] + public static Command operator |(Command source, (PipeTarget stdOut, PipeTarget stdErr) targets) + => source.WithStandardOutputPipe(targets.stdOut).WithStandardErrorPipe(targets.stdErr); + + /// + /// Creates a new command that pipes its standard output and standard error to the + /// specified streams. + /// + [Pure] + public static Command operator |(Command source, (Stream stdOut, Stream stdErr) targets) + => source | (PipeTarget.ToStream(targets.stdOut), PipeTarget.ToStream(targets.stdErr)); + + /// + /// Creates a new command that pipes its standard output and standard error to the + /// specified string builders. + /// Uses for decoding. + /// + [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)); + + /// + /// Creates a new command that pipes its standard output and standard error line-by-line + /// to the specified asynchronous delegates. + /// Uses for decoding. + /// + [Pure] + public static Command operator |(Command source, (Func stdOut, Func stdErr) targets) + => source | (PipeTarget.ToDelegate(targets.stdOut, Console.OutputEncoding), PipeTarget.ToDelegate(targets.stdErr, Console.OutputEncoding)); + + /// + /// Creates a new command that pipes its standard output and standard error line-by-line + /// to the specified asynchronous delegates. + /// Uses for decoding. + /// + [Pure] + public static Command operator |(Command source, (Func stdOut, Func stdErr) targets) + => source | (PipeTarget.ToDelegate(targets.stdOut, Console.OutputEncoding), PipeTarget.ToDelegate(targets.stdErr, Console.OutputEncoding)); + + /// + /// Creates a new command that pipes its standard output and standard error line-by-line + /// to the specified synchronous delegates. + /// Uses for decoding. + /// + [Pure] + public static Command operator |(Command source, (Action stdOut, Action stdErr) targets) + => source | (PipeTarget.ToDelegate(targets.stdOut, Console.OutputEncoding), PipeTarget.ToDelegate(targets.stdErr, Console.OutputEncoding)); + + /// + /// Creates a new command that pipes its standard input from the specified source. + /// + [Pure] + public static Command operator |(PipeSource source, Command target) + => target.WithStandardInputPipe(source); + + /// + /// Creates a new command that pipes its standard input from the specified stream. + /// + [Pure] + public static Command operator |(Stream source, Command target) + => PipeSource.FromStream(source) | target; + + /// + /// Creates a new command that pipes its standard input from the specified memory buffer. + /// + [Pure] + public static Command operator |(ReadOnlyMemory source, Command target) + => PipeSource.FromBytes(source) | target; + + /// + /// Creates a new command that pipes its standard input from the specified byte array. + /// + [Pure] + public static Command operator |(byte[] source, Command target) + => PipeSource.FromBytes(source) | target; + + /// + /// Creates a new command that pipes its standard input from the specified string. + /// Uses for encoding. + /// + [Pure] + public static Command operator |(string source, Command target) + => PipeSource.FromString(source, Console.InputEncoding) | target; + + /// + /// Creates a new command that pipes its standard input from the standard output of the + /// specified command. + /// + [Pure] + public static Command operator |(Command source, Command target) + => PipeSource.FromCommand(source) | target; +} diff --git a/src/Process/Command.cs b/src/Process/Command.cs new file mode 100644 index 0000000..a7ed531 --- /dev/null +++ b/src/Process/Command.cs @@ -0,0 +1,65 @@ +namespace Geekeey.Extensions.Process; + +/// +/// Instructions for running a process. +/// +public sealed partial class Command( + string targetFilePath, + string arguments, + string workingDirPath, + IReadOnlyDictionary environmentVariables, + CommandExitBehaviour validation, + PipeSource standardInputPipe, + PipeTarget standardOutputPipe, + PipeTarget standardErrorPipe +) +{ + /// + /// Initializes an instance of . + /// + public Command(string targetFilePath) : this(targetFilePath, string.Empty, Directory.GetCurrentDirectory(), + new Dictionary(), + CommandExitBehaviour.ZeroExitCode, PipeSource.Null, PipeTarget.Null, PipeTarget.Null) + { + } + + /// + /// File path of the executable, batch file, or script, that this command runs. + /// + public string TargetFilePath { get; } = targetFilePath; + + /// + /// File path of the executable, batch file, or script, that this command runs. + /// + public string Arguments { get; } = arguments; + + /// + /// File path of the executable, batch file, or script, that this command runs. + /// + public string WorkingDirPath { get; } = workingDirPath; + + /// + /// Environment variables set for the underlying process. + /// + public IReadOnlyDictionary EnvironmentVariables { get; } = environmentVariables; + + /// + /// Strategy for validating the result of the execution. + /// + public CommandExitBehaviour Validation { get; } = validation; + + /// + /// Pipe source for the standard input stream of the underlying process. + /// + public PipeSource StandardInputPipe { get; } = standardInputPipe; + + /// + /// Pipe target for the standard output stream of the underlying process. + /// + public PipeTarget StandardOutputPipe { get; } = standardOutputPipe; + + /// + /// Pipe target for the standard error stream of the underlying process. + /// + public PipeTarget StandardErrorPipe { get; } = standardErrorPipe; +} \ No newline at end of file diff --git a/src/Process/CommandExecutionException.cs b/src/Process/CommandExecutionException.cs new file mode 100644 index 0000000..24c4e4e --- /dev/null +++ b/src/Process/CommandExecutionException.cs @@ -0,0 +1,23 @@ +using Geekeey.Extensions.Process; + +namespace Geekeey.Extensions.Process; + +/// +/// Exception thrown when the command fails to execute correctly. +/// +public class CommandExecutionException( + Command command, + int exitCode, + string message, + Exception? innerException = null) : Exception(message, innerException) +{ + /// + /// Command that triggered the exception. + /// + public Command Command { get; } = command; + + /// + /// Exit code returned by the process. + /// + public int ExitCode { get; } = exitCode; +} \ No newline at end of file diff --git a/src/Process/CommandExitBehaviour.cs b/src/Process/CommandExitBehaviour.cs new file mode 100644 index 0000000..ffe6283 --- /dev/null +++ b/src/Process/CommandExitBehaviour.cs @@ -0,0 +1,18 @@ +namespace Geekeey.Extensions.Process; + +/// +/// Strategy used for validating the result of a command execution. +/// +[Flags] +public enum CommandExitBehaviour +{ + /// + /// No validation. + /// + None = 0b0, + + /// + /// Ensure that the command returned a zero exit code. + /// + ZeroExitCode = 0b1 +} \ No newline at end of file diff --git a/src/Process/CommandResult.cs b/src/Process/CommandResult.cs new file mode 100644 index 0000000..125aec5 --- /dev/null +++ b/src/Process/CommandResult.cs @@ -0,0 +1,35 @@ +namespace Geekeey.Extensions.Process; + +/// +/// Represents the result of a command execution. +/// +public class CommandResult( + int exitCode, + DateTimeOffset startTime, + DateTimeOffset exitTime) +{ + /// + /// Exit code set by the underlying process. + /// + public int ExitCode { get; } = exitCode; + + /// + /// Whether the command execution was successful (i.e. exit code is zero). + /// + public bool IsSuccess => ExitCode is 0; + + /// + /// Time at which the command started executing. + /// + public DateTimeOffset StartTime { get; } = startTime; + + /// + /// Time at which the command finished executing. + /// + public DateTimeOffset ExitTime { get; } = exitTime; + + /// + /// Total duration of the command execution. + /// + public TimeSpan RunTime => ExitTime - StartTime; +} \ No newline at end of file diff --git a/src/Process/CommandTask.cs b/src/Process/CommandTask.cs new file mode 100644 index 0000000..e288d16 --- /dev/null +++ b/src/Process/CommandTask.cs @@ -0,0 +1,58 @@ +using System.Runtime.CompilerServices; + +namespace Geekeey.Extensions.Process; + +/// +/// Represents an asynchronous execution of a command. +/// +public partial class CommandTask : IDisposable +{ + internal CommandTask(Task task, int processId) + { + Task = task; + ProcessId = processId; + } + + /// + /// Underlying task. + /// + public Task Task { get; } + + /// + /// Underlying process ID. + /// + public int ProcessId { get; } + + internal CommandTask Bind(Func, Task> transform) + => new(transform(Task), ProcessId); + + /// + /// Lazily maps the result of the task using the specified transform. + /// + internal CommandTask Select(Func transform) + => Bind(async task => transform(await task)); + + /// + /// Gets the awaiter of the underlying task. + /// Used to enable await expressions on this object. + /// + public TaskAwaiter GetAwaiter() + => Task.GetAwaiter(); + + /// + /// Configures an awaiter used to await this task. + /// + public ConfiguredTaskAwaitable ConfigureAwait(bool continueOnCapturedContext) + => Task.ConfigureAwait(continueOnCapturedContext); + + /// + public void Dispose() => Task.Dispose(); +} + +public partial class CommandTask +{ + /// + /// Converts the command task into a regular task. + /// + public static implicit operator Task(CommandTask commandTask) => commandTask.Task; +} \ No newline at end of file diff --git a/src/Process/Execution/ProcessHandle.Posix.cs b/src/Process/Execution/ProcessHandle.Posix.cs new file mode 100644 index 0000000..7152566 --- /dev/null +++ b/src/Process/Execution/ProcessHandle.Posix.cs @@ -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 + } +} \ No newline at end of file diff --git a/src/Process/Execution/ProcessHandle.Windows.cs b/src/Process/Execution/ProcessHandle.Windows.cs new file mode 100644 index 0000000..41813e3 --- /dev/null +++ b/src/Process/Execution/ProcessHandle.Windows.cs @@ -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 + } +} \ No newline at end of file diff --git a/src/Process/Execution/ProcessHandle.cs b/src/Process/Execution/ProcessHandle.cs new file mode 100644 index 0000000..6498685 --- /dev/null +++ b/src/Process/Execution/ProcessHandle.cs @@ -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 _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(); +} \ No newline at end of file diff --git a/src/Process/IO/BufferSizes.cs b/src/Process/IO/BufferSizes.cs new file mode 100644 index 0000000..a07131f --- /dev/null +++ b/src/Process/IO/BufferSizes.cs @@ -0,0 +1,7 @@ +namespace Geekeey.Extensions.Process; + +internal static class BufferSizes +{ + public const int Stream = 81920; + public const int StreamReader = 1024; +} \ No newline at end of file diff --git a/src/Process/IO/MemoryBufferStream.cs b/src/Process/IO/MemoryBufferStream.cs new file mode 100644 index 0000000..60959c8 --- /dev/null +++ b/src/Process/IO/MemoryBufferStream.cs @@ -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 _sharedBuffer = MemoryPool.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 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.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 + ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => + await ReadAsync(buffer.AsMemory(offset, count), cancellationToken); + + public override async ValueTask ReadAsync(Memory 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.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(); +} \ No newline at end of file diff --git a/src/Process/PipeSource.cs b/src/Process/PipeSource.cs new file mode 100644 index 0000000..3ca1987 --- /dev/null +++ b/src/Process/PipeSource.cs @@ -0,0 +1,102 @@ +using System.Text; + +namespace Geekeey.Extensions.Process; + +/// +/// Represents a pipe for the process's standard input stream. +/// +public abstract partial class PipeSource +{ + /// + /// Reads the binary content pushed into the pipe and writes it to the destination stream. + /// Destination stream represents the process's standard input stream. + /// + public abstract Task CopyToAsync(Stream destination, CancellationToken cancellationToken = default); +} + +public partial class PipeSource +{ + private class AnonymousPipeSource(Func func) : PipeSource + { + public override async Task CopyToAsync(Stream destination, CancellationToken cancellationToken = default) + => await func(destination, cancellationToken); + } +} + +public partial class PipeSource +{ + /// + /// Pipe source that does not provide any data. + /// Functionally equivalent to a null device. + /// + public static PipeSource Null { get; } = Create((_, cancellationToken) + => !cancellationToken.IsCancellationRequested ? Task.CompletedTask : Task.FromCanceled(cancellationToken)); + + /// + /// Creates an anonymous pipe source with the method + /// implemented by the specified asynchronous delegate. + /// + public static PipeSource Create(Func func) + => new AnonymousPipeSource(func); + + /// + /// Creates an anonymous pipe source with the method + /// implemented by the specified synchronous delegate. + /// + public static PipeSource Create(Action action) => Create( + (destination, _) => + { + action(destination); + return Task.CompletedTask; + }); + + /// + /// Creates a pipe source that reads from the specified stream. + /// + public static PipeSource FromStream(Stream stream) => Create( + async (destination, cancellationToken) => + await stream.CopyToAsync(destination, cancellationToken)); + + /// + /// Creates a pipe source that reads from the specified file. + /// + public static PipeSource FromFile(string filePath) => Create( + async (destination, cancellationToken) => + { + await using var source = File.OpenRead(filePath); + await source.CopyToAsync(destination, cancellationToken); + }); + + /// + /// Creates a pipe source that reads from the specified memory buffer. + /// + public static PipeSource FromBytes(ReadOnlyMemory data) => Create( + async (destination, cancellationToken) => + await destination.WriteAsync(data, cancellationToken)); + + /// + /// Creates a pipe source that reads from the specified byte array. + /// + public static PipeSource FromBytes(byte[] data) + => FromBytes((ReadOnlyMemory)data); + + /// + /// Creates a pipe source that reads from the specified string. + /// + public static PipeSource FromString(string str, Encoding encoding) + => FromBytes(encoding.GetBytes(str)); + + /// + /// Creates a pipe source that reads from the specified string. + /// Uses for encoding. + /// + public static PipeSource FromString(string str) + => FromString(str, Console.InputEncoding); + + /// + /// Creates a pipe source that reads from the standard output of the specified command. + /// + public static PipeSource FromCommand(Command command) => Create( + async (destination, cancellationToken) => + await command.WithStandardOutputPipe(PipeTarget.ToStream(destination)).ExecuteAsync(cancellationToken)); +} \ No newline at end of file diff --git a/src/Process/PipeTarget.cs b/src/Process/PipeTarget.cs new file mode 100644 index 0000000..2e154b7 --- /dev/null +++ b/src/Process/PipeTarget.cs @@ -0,0 +1,280 @@ +using System.Buffers; +using System.Text; + +namespace Geekeey.Extensions.Process; + +/// +/// Represents a pipe for the process's standard output or standard error stream. +/// +public abstract partial class PipeTarget +{ + /// + /// 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. + /// + public abstract Task CopyFromAsync(Stream origin, CancellationToken cancellationToken = default); +} + +public partial class PipeTarget +{ + private class AnonymousPipeTarget(Func func) : PipeTarget + { + public override async Task CopyFromAsync(Stream origin, CancellationToken cancellationToken = default) + => await func(origin, cancellationToken); + } + + private class AggregatePipeTarget(IReadOnlyList targets) : PipeTarget + { + public IReadOnlyList 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(); + foreach (var target in Targets) + { + targetSubStreams[target] = new MemoryBufferStream(); + } + + try + { + // start piping in the background + async Task StartCopyAsync(KeyValuePair 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.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 +{ + /// + /// Pipe target that discards all data. Functionally equivalent to a null device. + /// + /// + /// 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 with . + /// + public static PipeTarget Null { get; } = Create((_, cancellationToken) => + !cancellationToken.IsCancellationRequested ? Task.CompletedTask : Task.FromCanceled(cancellationToken)); + + /// + /// Creates an anonymous pipe target with the method + /// implemented by the specified asynchronous delegate. + /// + public static PipeTarget Create(Func func) + => new AnonymousPipeTarget(func); + + /// + /// Creates an anonymous pipe target with the method + /// implemented by the specified synchronous delegate. + /// + public static PipeTarget Create(Action action) => Create( + (origin, _) => + { + action(origin); + return Task.CompletedTask; + }); + + /// + /// Creates a pipe target that writes to the specified stream. + /// + public static PipeTarget ToStream(Stream stream) => Create( + async (origin, cancellationToken) => + await origin.CopyToAsync(stream, cancellationToken)); + + /// + /// Creates a pipe target that writes to the specified file. + /// + public static PipeTarget ToFile(string filePath) => Create( + async (origin, cancellationToken) => + { + await using var target = File.Create(filePath); + await origin.CopyToAsync(target, cancellationToken); + }); + + /// + /// Creates a pipe target that writes to the specified string builder. + /// + 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.Shared.Rent(BufferSizes.StreamReader); + + while (!cancellationToken.IsCancellationRequested) + { + var charsRead = await reader.ReadAsync(buffer.Memory, cancellationToken); + if (charsRead <= 0) break; + stringBuilder.Append(buffer.Memory[..charsRead]); + } + }); + + /// + /// Creates a pipe target that writes to the specified string builder. + /// Uses for decoding. + /// + public static PipeTarget ToStringBuilder(StringBuilder stringBuilder) + => ToStringBuilder(stringBuilder, Console.OutputEncoding); + + /// + /// Creates a pipe target that invokes the specified asynchronous delegate on every line written to the stream. + /// + public static PipeTarget ToDelegate(Func 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); + } + }); + + /// + /// Creates a pipe target that invokes the specified asynchronous delegate on every line written to the stream. + /// Uses for decoding. + /// + public static PipeTarget ToDelegate(Func func) => + ToDelegate(func, Console.OutputEncoding); + + /// + /// Creates a pipe target that invokes the specified asynchronous delegate on every line written to the stream. + /// + public static PipeTarget ToDelegate(Func func, Encoding encoding) => ToDelegate( + async (line, _) => await func(line), encoding); + + /// + /// Creates a pipe target that invokes the specified asynchronous delegate on every line written to the stream. + /// Uses for decoding. + /// + public static PipeTarget ToDelegate(Func func) => ToDelegate(func, Console.OutputEncoding); + + /// + /// Creates a pipe target that invokes the specified synchronous delegate on every line written to the stream. + /// + public static PipeTarget ToDelegate(Action action, Encoding encoding) => ToDelegate( + line => + { + action(line); + return Task.CompletedTask; + }, encoding); + + /// + /// Creates a pipe target that invokes the specified synchronous delegate on every line written to the stream. + /// Uses for decoding. + /// + public static PipeTarget ToDelegate(Action action) + => ToDelegate(action, Console.OutputEncoding); + + /// + /// Creates a pipe target that replicates data over multiple inner targets. + /// + public static PipeTarget Merge(IEnumerable 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 OptimizeTargets(IEnumerable targets) + { + var result = new List(); + + // unwrap merged targets + UnwrapTargets(targets, result); + + // filter out no-op + result.RemoveAll(t => t == Null); + + return result; + } + + static void UnwrapTargets(IEnumerable targets, ICollection output) + { + foreach (var target in targets) + { + if (target is AggregatePipeTarget mergedTarget) + { + UnwrapTargets(mergedTarget.Targets, output); + } + else + { + output.Add(target); + } + } + } + } + + /// + /// Creates a pipe target that replicates data over multiple inner targets. + /// + public static PipeTarget Merge(params PipeTarget[] targets) => Merge((IEnumerable)targets); +} \ No newline at end of file diff --git a/src/Process/Prelude.cs b/src/Process/Prelude.cs new file mode 100644 index 0000000..b3ac1b2 --- /dev/null +++ b/src/Process/Prelude.cs @@ -0,0 +1,18 @@ +namespace Geekeey.Extensions.Process; + +/// +/// A class containing various utility methods, a 'prelude' to the rest of the library. +/// +/// +/// This class is meant to be imported statically, e.g. using static Geekeey.Extensions.Process.Prelude;. +/// Recommended to be imported globally via a global using statement. +/// +public static class Prelude +{ + /// + /// Creates a new command that targets the specified command-line executable, batch file, or script. + /// + /// The name of the file to be executed. + /// A command representing the parameters used to execute the executable. + public static Command Run(string targetFilePath) => new(targetFilePath); +} \ No newline at end of file diff --git a/src/Process/Process.csproj b/src/Process/Process.csproj new file mode 100644 index 0000000..f28fed0 --- /dev/null +++ b/src/Process/Process.csproj @@ -0,0 +1,19 @@ + + + true + + + + + true + + + + + + + + + + + diff --git a/src/Process/Project.props b/src/Process/Project.props new file mode 100644 index 0000000..a64e93e --- /dev/null +++ b/src/Process/Project.props @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/Process/package-readme.md b/src/Process/package-readme.md new file mode 100644 index 0000000..e69de29