From b54acec2f220badf409f68773b54d045b80d8792 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 | 42 ++ LICENSE.md | 287 ++++++++++ global.json | 9 + nuget.config | 19 + src/Process.Tests.Dummy/AsyncOutputCommand.cs | 12 + .../Commands/EchoCommand.cs | 24 + .../Commands/EchoStdinCommand.cs | 38 ++ .../Commands/EnvironmentCommand.cs | 28 + .../Commands/ExitCommand.cs | 20 + .../Commands/GenerateBlobCommand.cs | 40 ++ .../Commands/GenerateClobCommand.cs | 42 ++ .../Commands/LengthCommand.cs | 37 ++ .../Commands/SleepCommand.cs | 33 ++ .../Commands/WorkingDirectoryCommand.cs | 22 + src/Process.Tests.Dummy/OutputTarget.cs | 21 + .../Process.Tests.Dummy.csproj | 11 + src/Process.Tests.Dummy/Program.cs | 44 ++ src/Process.Tests.Dummy/Terminal.cs | 41 ++ src/Process.Tests/BufferedExecuteTests.cs | 85 +++ src/Process.Tests/CancellationTests.cs | 177 ++++++ src/Process.Tests/CommandTests.cs | 271 +++++++++ src/Process.Tests/ExecuteTests.cs | 137 +++++ src/Process.Tests/Fixtures/ProcessTree.cs | 18 + src/Process.Tests/Fixtures/TestEnvironment.cs | 19 + .../Fixtures/TestTempDirectory.cs | 28 + src/Process.Tests/Fixtures/TestTempFile.cs | 30 + src/Process.Tests/LineBreakTests.cs | 77 +++ src/Process.Tests/PathResolutionTests.cs | 48 ++ src/Process.Tests/PipingTests.cs | 520 ++++++++++++++++++ src/Process.Tests/Process.Tests.csproj | 23 + src/Process.Tests/Properties/Assembly.cs | 2 + src/Process.Tests/ValidationTests.cs | 65 +++ .../Process.Win.Notify.csproj | 8 + src/Process.Win.Notify/Program.cs | 43 ++ src/Process.Win.Notify/README.md | 15 + .../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 | 197 +++++++ src/Process/Command.Piping.cs | 157 ++++++ src/Process/Command.cs | 65 +++ src/Process/CommandExecutionException.cs | 23 + src/Process/CommandExitBehaviour.cs | 18 + src/Process/CommandResult.cs | 35 ++ src/Process/CommandTask.cs | 71 +++ src/Process/Execution/ProcessHandle.Posix.cs | 30 + .../Execution/ProcessHandle.Windows.cs | 79 +++ src/Process/Execution/ProcessHandle.cs | 122 ++++ 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 | 2 + 66 files changed, 5135 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 LICENSE.md create mode 100644 global.json create mode 100644 nuget.config create mode 100644 src/Process.Tests.Dummy/AsyncOutputCommand.cs 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/GenerateBlobCommand.cs create mode 100644 src/Process.Tests.Dummy/Commands/GenerateClobCommand.cs create mode 100644 src/Process.Tests.Dummy/Commands/LengthCommand.cs create mode 100644 src/Process.Tests.Dummy/Commands/SleepCommand.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.Dummy/Terminal.cs create mode 100644 src/Process.Tests/BufferedExecuteTests.cs create mode 100644 src/Process.Tests/CancellationTests.cs create mode 100644 src/Process.Tests/CommandTests.cs create mode 100644 src/Process.Tests/ExecuteTests.cs create mode 100644 src/Process.Tests/Fixtures/ProcessTree.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/LineBreakTests.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/Properties/Assembly.cs create mode 100644 src/Process.Tests/ValidationTests.cs create mode 100644 src/Process.Win.Notify/Process.Win.Notify.csproj create mode 100644 src/Process.Win.Notify/Program.cs create mode 100644 src/Process.Win.Notify/README.md 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..7285ed2 --- /dev/null +++ b/Geekeey.Extensions.Process.sln @@ -0,0 +1,42 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Process", "src\Process\Process.csproj", "{0B246E7A-565E-45E7-84C2-37A43C0982A3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Process.Tests", "src\Process.Tests\Process.Tests.csproj", "{C0919570-F420-49F5-A8B4-B5DF16A8EC05}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Process.Tests.Dummy", "src\Process.Tests.Dummy\Process.Tests.Dummy.csproj", "{8E6F465E-3FEE-4789-A750-9FED80CCCB8E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Process.Win.Notify", "src\Process.Win.Notify\Process.Win.Notify.csproj", "{4D83262D-E5A9-441E-854A-41CA0D7EBD11}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {0B246E7A-565E-45E7-84C2-37A43C0982A3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0B246E7A-565E-45E7-84C2-37A43C0982A3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0B246E7A-565E-45E7-84C2-37A43C0982A3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0B246E7A-565E-45E7-84C2-37A43C0982A3}.Release|Any CPU.Build.0 = Release|Any CPU + {C0919570-F420-49F5-A8B4-B5DF16A8EC05}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C0919570-F420-49F5-A8B4-B5DF16A8EC05}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C0919570-F420-49F5-A8B4-B5DF16A8EC05}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C0919570-F420-49F5-A8B4-B5DF16A8EC05}.Release|Any CPU.Build.0 = Release|Any CPU + {8E6F465E-3FEE-4789-A750-9FED80CCCB8E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8E6F465E-3FEE-4789-A750-9FED80CCCB8E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8E6F465E-3FEE-4789-A750-9FED80CCCB8E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8E6F465E-3FEE-4789-A750-9FED80CCCB8E}.Release|Any CPU.Build.0 = Release|Any CPU + {4D83262D-E5A9-441E-854A-41CA0D7EBD11}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4D83262D-E5A9-441E-854A-41CA0D7EBD11}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4D83262D-E5A9-441E-854A-41CA0D7EBD11}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4D83262D-E5A9-441E-854A-41CA0D7EBD11}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + EndGlobalSection +EndGlobal diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..4153cd3 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,287 @@ + EUROPEAN UNION PUBLIC LICENCE v. 1.2 + EUPL © the European Union 2007, 2016 + +This European Union Public Licence (the ‘EUPL’) applies to the Work (as defined +below) which is provided under the terms of this Licence. Any use of the Work, +other than as authorised under this Licence is prohibited (to the extent such +use is covered by a right of the copyright holder of the Work). + +The Work is provided under the terms of this Licence when the Licensor (as +defined below) has placed the following notice immediately following the +copyright notice for the Work: + + Licensed under the EUPL + +or has expressed by any other means his willingness to license under the EUPL. + +1. Definitions + +In this Licence, the following terms have the following meaning: + +- ‘The Licence’: this Licence. + +- ‘The Original Work’: the work or software distributed or communicated by the + Licensor under this Licence, available as Source Code and also as Executable + Code as the case may be. + +- ‘Derivative Works’: the works or software that could be created by the + Licensee, based upon the Original Work or modifications thereof. This Licence + does not define the extent of modification or dependence on the Original Work + required in order to classify a work as a Derivative Work; this extent is + determined by copyright law applicable in the country mentioned in Article 15. + +- ‘The Work’: the Original Work or its Derivative Works. + +- ‘The Source Code’: the human-readable form of the Work which is the most + convenient for people to study and modify. + +- ‘The Executable Code’: any code which has generally been compiled and which is + meant to be interpreted by a computer as a program. + +- ‘The Licensor’: the natural or legal person that distributes or communicates + the Work under the Licence. + +- ‘Contributor(s)’: any natural or legal person who modifies the Work under the + Licence, or otherwise contributes to the creation of a Derivative Work. + +- ‘The Licensee’ or ‘You’: any natural or legal person who makes any usage of + the Work under the terms of the Licence. + +- ‘Distribution’ or ‘Communication’: any act of selling, giving, lending, + renting, distributing, communicating, transmitting, or otherwise making + available, online or offline, copies of the Work or providing access to its + essential functionalities at the disposal of any other natural or legal + person. + +2. Scope of the rights granted by the Licence + +The Licensor hereby grants You a worldwide, royalty-free, non-exclusive, +sublicensable licence to do the following, for the duration of copyright vested +in the Original Work: + +- use the Work in any circumstance and for all usage, +- reproduce the Work, +- modify the Work, and make Derivative Works based upon the Work, +- communicate to the public, including the right to make available or display + the Work or copies thereof to the public and perform publicly, as the case may + be, the Work, +- distribute the Work or copies thereof, +- lend and rent the Work or copies thereof, +- sublicense rights in the Work or copies thereof. + +Those rights can be exercised on any media, supports and formats, whether now +known or later invented, as far as the applicable law permits so. + +In the countries where moral rights apply, the Licensor waives his right to +exercise his moral right to the extent allowed by law in order to make effective +the licence of the economic rights here above listed. + +The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to +any patents held by the Licensor, to the extent necessary to make use of the +rights granted on the Work under this Licence. + +3. Communication of the Source Code + +The Licensor may provide the Work either in its Source Code form, or as +Executable Code. If the Work is provided as Executable Code, the Licensor +provides in addition a machine-readable copy of the Source Code of the Work +along with each copy of the Work that the Licensor distributes or indicates, in +a notice following the copyright notice attached to the Work, a repository where +the Source Code is easily and freely accessible for as long as the Licensor +continues to distribute or communicate the Work. + +4. Limitations on copyright + +Nothing in this Licence is intended to deprive the Licensee of the benefits from +any exception or limitation to the exclusive rights of the rights owners in the +Work, of the exhaustion of those rights or of other applicable limitations +thereto. + +5. Obligations of the Licensee + +The grant of the rights mentioned above is subject to some restrictions and +obligations imposed on the Licensee. Those obligations are the following: + +Attribution right: The Licensee shall keep intact all copyright, patent or +trademarks notices and all notices that refer to the Licence and to the +disclaimer of warranties. The Licensee must include a copy of such notices and a +copy of the Licence with every copy of the Work he/she distributes or +communicates. The Licensee must cause any Derivative Work to carry prominent +notices stating that the Work has been modified and the date of modification. + +Copyleft clause: If the Licensee distributes or communicates copies of the +Original Works or Derivative Works, this Distribution or Communication will be +done under the terms of this Licence or of a later version of this Licence +unless the Original Work is expressly distributed only under this version of the +Licence — for example by communicating ‘EUPL v. 1.2 only’. The Licensee +(becoming Licensor) cannot offer or impose any additional terms or conditions on +the Work or Derivative Work that alter or restrict the terms of the Licence. + +Compatibility clause: If the Licensee Distributes or Communicates Derivative +Works or copies thereof based upon both the Work and another work licensed under +a Compatible Licence, this Distribution or Communication can be done under the +terms of this Compatible Licence. For the sake of this clause, ‘Compatible +Licence’ refers to the licences listed in the appendix attached to this Licence. +Should the Licensee's obligations under the Compatible Licence conflict with +his/her obligations under this Licence, the obligations of the Compatible +Licence shall prevail. + +Provision of Source Code: When distributing or communicating copies of the Work, +the Licensee will provide a machine-readable copy of the Source Code or indicate +a repository where this Source will be easily and freely available for as long +as the Licensee continues to distribute or communicate the Work. + +Legal Protection: This Licence does not grant permission to use the trade names, +trademarks, service marks, or names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the copyright notice. + +6. Chain of Authorship + +The original Licensor warrants that the copyright in the Original Work granted +hereunder is owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each Contributor warrants that the copyright in the modifications he/she brings +to the Work are owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each time You accept the Licence, the original Licensor and subsequent +Contributors grant You a licence to their contributions to the Work, under the +terms of this Licence. + +7. Disclaimer of Warranty + +The Work is a work in progress, which is continuously improved by numerous +Contributors. It is not a finished work and may therefore contain defects or +‘bugs’ inherent to this type of development. + +For the above reason, the Work is provided under the Licence on an ‘as is’ basis +and without warranties of any kind concerning the Work, including without +limitation merchantability, fitness for a particular purpose, absence of defects +or errors, accuracy, non-infringement of intellectual property rights other than +copyright as stated in Article 6 of this Licence. + +This disclaimer of warranty is an essential part of the Licence and a condition +for the grant of any rights to the Work. + +8. Disclaimer of Liability + +Except in the cases of wilful misconduct or damages directly caused to natural +persons, the Licensor will in no event be liable for any direct or indirect, +material or moral, damages of any kind, arising out of the Licence or of the use +of the Work, including without limitation, damages for loss of goodwill, work +stoppage, computer failure or malfunction, loss of data or any commercial +damage, even if the Licensor has been advised of the possibility of such damage. +However, the Licensor will be liable under statutory product liability laws as +far such laws apply to the Work. + +9. Additional agreements + +While distributing the Work, You may choose to conclude an additional agreement, +defining obligations or services consistent with this Licence. However, if +accepting obligations, You may act only on your own behalf and on your sole +responsibility, not on behalf of the original Licensor or any other Contributor, +and only if You agree to indemnify, defend, and hold each Contributor harmless +for any liability incurred by, or claims asserted against such Contributor by +the fact You have accepted any warranty or additional liability. + +10. Acceptance of the Licence + +The provisions of this Licence can be accepted by clicking on an icon ‘I agree’ +placed under the bottom of a window displaying the text of this Licence or by +affirming consent in any other similar way, in accordance with the rules of +applicable law. Clicking on that icon indicates your clear and irrevocable +acceptance of this Licence and all of its terms and conditions. + +Similarly, you irrevocably accept this Licence and all of its terms and +conditions by exercising any rights granted to You by Article 2 of this Licence, +such as the use of the Work, the creation by You of a Derivative Work or the +Distribution or Communication by You of the Work or copies thereof. + +11. Information to the public + +In case of any Distribution or Communication of the Work by means of electronic +communication by You (for example, by offering to download the Work from a +remote location) the distribution channel or media (for example, a website) must +at least provide to the public the information requested by the applicable law +regarding the Licensor, the Licence and the way it may be accessible, concluded, +stored and reproduced by the Licensee. + +12. Termination of the Licence + +The Licence and the rights granted hereunder will terminate automatically upon +any breach by the Licensee of the terms of the Licence. + +Such a termination will not terminate the licences of any person who has +received the Work from the Licensee under the Licence, provided such persons +remain in full compliance with the Licence. + +13. Miscellaneous + +Without prejudice of Article 9 above, the Licence represents the complete +agreement between the Parties as to the Work. + +If any provision of the Licence is invalid or unenforceable under applicable +law, this will not affect the validity or enforceability of the Licence as a +whole. Such provision will be construed or reformed so as necessary to make it +valid and enforceable. + +The European Commission may publish other linguistic versions or new versions of +this Licence or updated versions of the Appendix, so far this is required and +reasonable, without reducing the scope of the rights granted by the Licence. New +versions of the Licence will be published with a unique version number. + +All linguistic versions of this Licence, approved by the European Commission, +have identical value. Parties can take advantage of the linguistic version of +their choice. + +14. Jurisdiction + +Without prejudice to specific agreement between parties, + +- any litigation resulting from the interpretation of this License, arising + between the European Union institutions, bodies, offices or agencies, as a + Licensor, and any Licensee, will be subject to the jurisdiction of the Court + of Justice of the European Union, as laid down in article 272 of the Treaty on + the Functioning of the European Union, + +- any litigation arising between other parties and resulting from the + interpretation of this License, will be subject to the exclusive jurisdiction + of the competent court where the Licensor resides or conducts its primary + business. + +15. Applicable Law + +Without prejudice to specific agreement between parties, + +- this Licence shall be governed by the law of the European Union Member State + where the Licensor has his seat, resides or has his registered office, + +- this licence shall be governed by Belgian law if the Licensor has no seat, + residence or registered office inside a European Union Member State. + +Appendix + +‘Compatible Licences’ according to Article 5 EUPL are: + +- GNU General Public License (GPL) v. 2, v. 3 +- GNU Affero General Public License (AGPL) v. 3 +- Open Software License (OSL) v. 2.1, v. 3.0 +- Eclipse Public License (EPL) v. 1.0 +- CeCILL v. 2.0, v. 2.1 +- Mozilla Public Licence (MPL) v. 2 +- GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3 +- Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for + works other than software +- European Union Public Licence (EUPL) v. 1.1, v. 1.2 +- Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong + Reciprocity (LiLiQ-R+). + +The European Commission may update this Appendix to later versions of the above +licences without producing a new version of the EUPL, as long as they provide +the rights granted in Article 2 of this Licence and protect the covered Source +Code from exclusive appropriation. + +All other changes or additions to this Appendix require the production of a new +EUPL version. 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/AsyncOutputCommand.cs b/src/Process.Tests.Dummy/AsyncOutputCommand.cs new file mode 100644 index 0000000..139652e --- /dev/null +++ b/src/Process.Tests.Dummy/AsyncOutputCommand.cs @@ -0,0 +1,12 @@ +using Spectre.Console.Cli; + +namespace Geekeey.Extensions.Process.Tests.Dummy; + +internal abstract class AsyncOutputCommand : AsyncCommand where T : OutputCommandSettings +{ +} + +internal abstract class OutputCommandSettings : CommandSettings +{ + [CommandOption("--target")] public OutputTarget Target { get; init; } = OutputTarget.StdOut; +} \ 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..d38f2fa --- /dev/null +++ b/src/Process.Tests.Dummy/Commands/EchoCommand.cs @@ -0,0 +1,24 @@ +using Spectre.Console.Cli; + +namespace Geekeey.Extensions.Process.Tests.Dummy.Commands; + +internal sealed class EchoCommand : AsyncOutputCommand +{ + public sealed class Settings : OutputCommandSettings + { + [CommandOption("--separator ")] public string Separator { get; init; } = " "; + [CommandArgument(0, "[line]")] public string[] Items { get; init; } = []; + } + + public override async Task ExecuteAsync(CommandContext context, Settings settings) + { + using var tty = Terminal.Connect(); + + foreach (var writer in tty.GetWriters(settings.Target)) + { + await writer.WriteLineAsync(string.Join(settings.Separator, settings.Items)); + } + + return 0; + } +} \ 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..8eea31a --- /dev/null +++ b/src/Process.Tests.Dummy/Commands/EchoStdinCommand.cs @@ -0,0 +1,38 @@ +using System.Buffers; + +using Spectre.Console.Cli; + +namespace Geekeey.Extensions.Process.Tests.Dummy.Commands; + +internal sealed class EchoStdinCommand : AsyncOutputCommand +{ + public sealed class Settings : OutputCommandSettings + { + [CommandOption("--length")] public long Length { get; init; } = long.MaxValue; + } + + public override async Task ExecuteAsync(CommandContext context, Settings settings) + { + using var tty = Terminal.Connect(); + using var buffer = MemoryPool.Shared.Rent(81920); + + var count = 0L; + while (count < settings.Length) + { + var bytesWanted = (int)Math.Min(buffer.Memory.Length, settings.Length - count); + + var bytesRead = await tty.Stdin.BaseStream.ReadAsync(buffer.Memory[..bytesWanted]); + if (bytesRead <= 0) + break; + + foreach (var writer in tty.GetWriters(settings.Target)) + { + await writer.BaseStream.WriteAsync(buffer.Memory[..bytesRead]); + } + + count += bytesRead; + } + + return 0; + } +} \ 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..36d8fb1 --- /dev/null +++ b/src/Process.Tests.Dummy/Commands/EnvironmentCommand.cs @@ -0,0 +1,28 @@ +using Spectre.Console.Cli; + +namespace Geekeey.Extensions.Process.Tests.Dummy.Commands; + +internal sealed class EnvironmentCommand : AsyncOutputCommand +{ + public sealed class Settings : OutputCommandSettings + { + [CommandArgument(0, "")] public string[] Variables { get; init; } = []; + } + + public override async Task ExecuteAsync(CommandContext context, Settings settings) + { + using var tty = Terminal.Connect(); + + foreach (var name in settings.Variables) + { + var value = Environment.GetEnvironmentVariable(name) ?? string.Empty; + + foreach (var writer in tty.GetWriters(settings.Target)) + { + await writer.WriteLineAsync(value); + } + } + + return 0; + } +} \ 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..e4b8884 --- /dev/null +++ b/src/Process.Tests.Dummy/Commands/ExitCommand.cs @@ -0,0 +1,20 @@ +using Spectre.Console.Cli; + +namespace Geekeey.Extensions.Process.Tests.Dummy.Commands; + +internal sealed class ExitCommand : AsyncCommand +{ + public sealed class Settings : CommandSettings + { + [CommandArgument(1, "")] public int Code { get; init; } + } + + public override async Task ExecuteAsync(CommandContext context, Settings settings) + { + using var tty = Terminal.Connect(); + + await tty.Stderr.WriteLineAsync($"Exit code set to {settings.Code}"); + + return settings.Code; + } +} \ No newline at end of file diff --git a/src/Process.Tests.Dummy/Commands/GenerateBlobCommand.cs b/src/Process.Tests.Dummy/Commands/GenerateBlobCommand.cs new file mode 100644 index 0000000..17add44 --- /dev/null +++ b/src/Process.Tests.Dummy/Commands/GenerateBlobCommand.cs @@ -0,0 +1,40 @@ +using System.Buffers; +using System.Text; + +using Spectre.Console.Cli; + +namespace Geekeey.Extensions.Process.Tests.Dummy.Commands; + +internal sealed class GenerateBlobCommand : AsyncOutputCommand +{ + private readonly Random _random = new(1234567); + + public sealed class Settings : OutputCommandSettings + { + [CommandOption("--length")] public long Length { get; init; } = 100_000; + [CommandOption("--buffer")] public int BufferSize { get; init; } = 1024; + } + + public override async Task ExecuteAsync(CommandContext context, Settings settings) + { + using var tty = Terminal.Connect(); + + using var bytes = MemoryPool.Shared.Rent(settings.BufferSize); + + var total = 0L; + while (total < settings.Length) + { + _random.NextBytes(bytes.Memory.Span); + + var count = (int)Math.Min(bytes.Memory.Length, settings.Length - total); + foreach (var writer in tty.GetWriters(settings.Target)) + { + await writer.BaseStream.WriteAsync(bytes.Memory[..count]); + } + + total += count; + } + + return 0; + } +} \ No newline at end of file diff --git a/src/Process.Tests.Dummy/Commands/GenerateClobCommand.cs b/src/Process.Tests.Dummy/Commands/GenerateClobCommand.cs new file mode 100644 index 0000000..f8acbc4 --- /dev/null +++ b/src/Process.Tests.Dummy/Commands/GenerateClobCommand.cs @@ -0,0 +1,42 @@ +using System.Buffers; +using System.Text; + +using Spectre.Console.Cli; + +namespace Geekeey.Extensions.Process.Tests.Dummy.Commands; + +internal sealed class GenerateClobCommand : AsyncOutputCommand +{ + private readonly Random _random = new(1234567); + private readonly char[] _chars = Enumerable.Range(32, 94).Select(i => (char)i).ToArray(); + + public sealed class Settings : OutputCommandSettings + { + [CommandOption("--length")] public int Length { get; init; } = 100_000; + [CommandOption("--lines")] public int LinesCount { get; init; } = 1; + } + + public override async Task ExecuteAsync(CommandContext context, Settings settings) + { + using var tty = Terminal.Connect(); + + var buffer = new StringBuilder(settings.Length); + + for (var line = 0; line < settings.LinesCount; line++) + { + buffer.Clear(); + + for (var i = 0; i < settings.Length; i++) + { + buffer.Append(_chars[_random.Next(0, _chars.Length)]); + } + + foreach (var writer in tty.GetWriters(settings.Target)) + { + await writer.WriteLineAsync(buffer.ToString()); + } + } + + return 0; + } +} \ No newline at end of file diff --git a/src/Process.Tests.Dummy/Commands/LengthCommand.cs b/src/Process.Tests.Dummy/Commands/LengthCommand.cs new file mode 100644 index 0000000..3f2ec96 --- /dev/null +++ b/src/Process.Tests.Dummy/Commands/LengthCommand.cs @@ -0,0 +1,37 @@ +using System.Buffers; +using System.Globalization; + +using Spectre.Console.Cli; + +namespace Geekeey.Extensions.Process.Tests.Dummy.Commands; + +internal sealed class LengthCommand : AsyncOutputCommand +{ + public sealed class Settings : OutputCommandSettings + { + } + + public override async Task ExecuteAsync(CommandContext context, Settings settings) + { + using var tty = Terminal.Connect(); + + using var buffer = MemoryPool.Shared.Rent(81920); + + var count = 0L; + while (true) + { + var bytesRead = await tty.Stdin.BaseStream.ReadAsync(buffer.Memory); + if (bytesRead <= 0) + break; + + count += bytesRead; + } + + foreach (var writer in tty.GetWriters(settings.Target)) + { + await writer.WriteLineAsync(count.ToString(CultureInfo.InvariantCulture)); + } + + return 0; + } +} \ No newline at end of file diff --git a/src/Process.Tests.Dummy/Commands/SleepCommand.cs b/src/Process.Tests.Dummy/Commands/SleepCommand.cs new file mode 100644 index 0000000..b43f554 --- /dev/null +++ b/src/Process.Tests.Dummy/Commands/SleepCommand.cs @@ -0,0 +1,33 @@ +using Spectre.Console.Cli; + +namespace Geekeey.Extensions.Process.Tests.Dummy.Commands; + +internal sealed class SleepCommand : AsyncCommand +{ + public sealed class Settings : CommandSettings + { + [CommandArgument(0, "[duration]")] public TimeSpan Duration { get; init; } = TimeSpan.FromSeconds(1); + } + + public override async Task ExecuteAsync(CommandContext context, Settings settings) + { + using var tty = Terminal.Connect(); + + try + { + await Console.Out.WriteLineAsync($"Sleeping for {settings.Duration}..."); + await Console.Out.FlushAsync(CancellationToken.None); + + await Task.Delay(settings.Duration, tty.CancellationToken); + } + catch (OperationCanceledException) + { + await Console.Out.WriteLineAsync("Canceled."); + await Console.Out.FlushAsync(CancellationToken.None); + } + + await Console.Out.WriteLineAsync("Done."); + await Console.Out.FlushAsync(CancellationToken.None); + return 0; + } +} \ 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..9b76a4e --- /dev/null +++ b/src/Process.Tests.Dummy/Commands/WorkingDirectoryCommand.cs @@ -0,0 +1,22 @@ +using Spectre.Console.Cli; + +namespace Geekeey.Extensions.Process.Tests.Dummy.Commands; + +internal sealed class WorkingDirectoryCommand : AsyncOutputCommand +{ + public sealed class Settings : OutputCommandSettings + { + } + + public override async Task ExecuteAsync(CommandContext context, Settings settings) + { + using var tty = Terminal.Connect(); + + foreach (var writer in tty.GetWriters(settings.Target)) + { + await writer.WriteLineAsync(Directory.GetCurrentDirectory()); + } + + return 0; + } +} \ 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..00cec7d --- /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 Terminal terminal, OutputTarget target) + { + if (target.HasFlag(OutputTarget.StdOut)) + yield return terminal.Stdout; + + if (target.HasFlag(OutputTarget.StdErr)) + yield return terminal.Stderr; + } +} \ 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..e8875db --- /dev/null +++ b/src/Process.Tests.Dummy/Process.Tests.Dummy.csproj @@ -0,0 +1,11 @@ + + + false + Exe + + + + + + + diff --git a/src/Process.Tests.Dummy/Program.cs b/src/Process.Tests.Dummy/Program.cs new file mode 100644 index 0000000..6b537d0 --- /dev/null +++ b/src/Process.Tests.Dummy/Program.cs @@ -0,0 +1,44 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +using Geekeey.Extensions.Process.Tests.Dummy.Commands; + +using Spectre.Console.Cli; + +namespace Geekeey.Extensions.Process.Tests.Dummy; + +public static class Program +{ + private static readonly string? FileExtension = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "exe" : null; + +#pragma warning disable IL3000 // only for testing where we don't run in single files! + private static readonly string AssemblyPath = Assembly.GetExecutingAssembly().Location; +#pragma warning restore IL3000 + + public static string FilePath { get; } = Path.ChangeExtension(AssemblyPath, FileExtension); + + private static Task 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("cwd"); + configuration.AddCommand("exit"); + configuration.AddCommand("length"); + configuration.AddCommand("sleep"); + configuration.AddBranch("generate", static generate => + { + generate.AddCommand("blob"); + generate.AddCommand("clob"); + }); + } + } +} \ No newline at end of file diff --git a/src/Process.Tests.Dummy/Terminal.cs b/src/Process.Tests.Dummy/Terminal.cs new file mode 100644 index 0000000..c857a36 --- /dev/null +++ b/src/Process.Tests.Dummy/Terminal.cs @@ -0,0 +1,41 @@ +namespace Geekeey.Extensions.Process.Tests.Dummy; + +internal sealed class Terminal : IDisposable +{ + private readonly CancellationTokenSource _cts = new(); + + public Terminal() + { + Console.CancelKeyPress += Cancel; + } + + public StreamReader Stdin { get; } = new(Console.OpenStandardInput(), leaveOpen: false); + + public StreamWriter Stdout { get; } = new(Console.OpenStandardOutput(), leaveOpen: false); + + public StreamWriter Stderr { get; } = new(Console.OpenStandardError(), leaveOpen: false); + + public CancellationToken CancellationToken => _cts.Token; + + public static Terminal Connect() + { + return new Terminal(); + } + + private void Cancel(object? sender, ConsoleCancelEventArgs args) + { + args.Cancel = true; + _cts.Cancel(); + } + + public void Dispose() + { + Stdout.BaseStream.Flush(); + Stdout.Dispose(); + Stderr.BaseStream.Flush(); + Stderr.Dispose(); + Stdin.Dispose(); + Console.CancelKeyPress -= Cancel; + _cts.Dispose(); + } +} \ 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..3ec324a --- /dev/null +++ b/src/Process.Tests/BufferedExecuteTests.cs @@ -0,0 +1,85 @@ +using Geekeey.Extensions.Process.Buffered; + +namespace Geekeey.Extensions.Process.Tests; + +[TestFixture] +internal sealed class BufferedExecuteTests +{ + private static Command Echo(string target, string value) => new Command(Dummy.Program.FilePath) + .WithArguments(["echo", "--target", target, value]); + + [Test] + public async Task I_can_execute_a_command_with_buffering_and_get_the_stdout() + { + // Arrange + var cmd = Echo("stdout", "Hello stdout"); + + // Act + var result = await cmd.ExecuteBufferedAsync(); + + Assume.That(result.ExitCode, Is.EqualTo(0)); + + // Assert + Assert.Multiple(() => + { + Assert.That(result.StandardOutput.Trim(), Is.EqualTo("Hello stdout")); + Assert.That(result.StandardError.Trim(), Is.Empty); + }); + } + + [Test] + public async Task I_can_execute_a_command_with_buffering_and_get_the_stderr() + { + // Arrange + var cmd = Echo("stderr", "Hello stderr"); + + // Act + var result = await cmd.ExecuteBufferedAsync(); + + Assume.That(result.ExitCode, Is.EqualTo(0)); + + // Assert + Assert.Multiple(() => + { + Assert.That(result.StandardOutput.Trim(), Is.Empty); + Assert.That(result.StandardError.Trim(), Is.EqualTo("Hello stderr")); + }); + } + + [Test] + public async Task I_can_execute_a_command_with_buffering_and_get_the_stdout_and_stderr() + { + // Arrange + var cmd = Echo("all", "Hello stdout and stderr"); + + // Act + var result = await cmd.ExecuteBufferedAsync(); + + Assume.That(result.ExitCode, Is.EqualTo(0)); + + // Assert + Assert.Multiple(() => + { + Assert.That(result.StandardOutput.Trim(), Is.EqualTo("Hello stdout and stderr")); + Assert.That(result.StandardError.Trim(), Is.EqualTo("Hello stdout and stderr")); + }); + } + + [Test] + public async Task I_can_execute_a_command_with_buffering_and_not_hang_on_large_stdout_and_stderr() + { + // Arrange + var cmd = new Command(Dummy.Program.FilePath) + .WithArguments(["generate", "clob", "--target", "all", "--length", "100000"]); + + // Act + var result = await cmd.ExecuteBufferedAsync(); + + // Assert + Assert.Multiple(() => + { + Assert.That(result.StandardOutput.Trim(), Is.Not.Empty.Or.Null); + Assert.That(result.StandardError.Trim(), Is.Not.Empty.Or.Null); + }); + } +} \ No newline at end of file diff --git a/src/Process.Tests/CancellationTests.cs b/src/Process.Tests/CancellationTests.cs new file mode 100644 index 0000000..b0e9d4c --- /dev/null +++ b/src/Process.Tests/CancellationTests.cs @@ -0,0 +1,177 @@ +using System.Text; + +using Geekeey.Extensions.Process.Buffered; + +namespace Geekeey.Extensions.Process.Tests; + +[TestFixture] +internal sealed class CancellationTests +{ + private static Action NotifyOnStart(out TaskCompletionSource tcs) + { + // run the continuation async on the thread pool, to allow the io reader to complte + var source = tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + return line => + { + if (line.Contains("Sleeping for", StringComparison.OrdinalIgnoreCase)) + source.SetResult(); + }; + } + + [Test] + public async Task I_can_execute_a_command_and_cancel_it_immediately() + { + // Arrange + using var cts = new CancellationTokenSource(); + + var stdout = new StringBuilder(); + + var target = PipeTarget.Merge( + PipeTarget.ToDelegate(NotifyOnStart(out var tcs)), + PipeTarget.ToStringBuilder(stdout) + ); + + var cmd = new Command(Dummy.Program.FilePath) + .WithArguments(["sleep", "00:00:30"]) + | target; + + // Act + var task = cmd.ExecuteAsync(cts.Token); + await tcs.Task; + await cts.CancelAsync(); + + // Assert + var exception = Assert.ThrowsAsync(async () => await task); + + Assert.Multiple(() => + { + Assert.That(ProcessTree.HasExited(task.ProcessId)); + Assert.That(stdout.ToString(), Does.Contain("Sleeping for")); + Assert.That(stdout.ToString(), Does.Not.Contain("Done.")); + }); + } + + [Test] + public async Task I_can_execute_a_command_and_kill_it_immediately() + { + // Arrange + var stdout = new StringBuilder(); + + var target = PipeTarget.Merge( + PipeTarget.ToDelegate(NotifyOnStart(out var tcs)), + PipeTarget.ToStringBuilder(stdout) + ); + + var cmd = new Command(Dummy.Program.FilePath) + .WithArguments(["sleep", "00:00:30"]) + | target; + + // Act + var task = cmd.ExecuteAsync(); + await tcs.Task; + task.Kill(); + + // Assert + var exception = Assert.ThrowsAsync(async () => await task); + + Assert.Multiple(() => + { + Assert.That(ProcessTree.HasExited(task.ProcessId)); + Assert.That(stdout.ToString(), Does.Contain("Sleeping for")); + Assert.That(stdout.ToString(), Does.Not.Contain("Done.")); + }); + } + + [Test] + public async Task I_can_execute_a_command_with_buffering_and_kill_it_immediately() + { + // Arrange + var stdout = new StringBuilder(); + + var target = PipeTarget.Merge( + PipeTarget.ToDelegate(NotifyOnStart(out var tcs)), + PipeTarget.ToStringBuilder(stdout) + ); + + var cmd = new Command(Dummy.Program.FilePath) + .WithArguments(["sleep", "00:00:30"]) + | target; + + // Act + var task = cmd.ExecuteBufferedAsync(); + await tcs.Task; + task.Kill(); + + // Assert + var exception = Assert.ThrowsAsync(async () => await task); + + Assert.Multiple(() => + { + Assert.That(ProcessTree.HasExited(task.ProcessId)); + Assert.That(stdout.ToString(), Does.Contain("Sleeping for")); + Assert.That(stdout.ToString(), Does.Not.Contain("Done.")); + }); + } + + [Test] + public async Task I_can_execute_a_command_and_interrupt_it_immediately() + { + // Arrange + var stdout = new StringBuilder(); + + var target = PipeTarget.Merge( + PipeTarget.ToDelegate(NotifyOnStart(out var tcs)), + PipeTarget.ToStringBuilder(stdout) + ); + + var cmd = new Command(Dummy.Program.FilePath) + .WithArguments(["sleep", "00:00:30"]) + | target; + + // Act + var task = cmd.ExecuteAsync(); + await tcs.Task; + task.Interrupt(); + + // Assert + Assert.DoesNotThrowAsync(async () => await task); + + Assert.Multiple(() => + { + Assert.That(ProcessTree.HasExited(task.ProcessId)); + Assert.That(stdout.ToString(), Does.Contain("Sleeping for")); + Assert.That(stdout.ToString(), Does.Contain("Done.")); + }); + } + + [Test] + public async Task I_can_execute_a_command_with_buffering_and_interrupt_it_immediately() + { + // Arrange + var stdout = new StringBuilder(); + + var target = PipeTarget.Merge( + PipeTarget.ToDelegate(NotifyOnStart(out var tcs)), + PipeTarget.ToStringBuilder(stdout) + ); + + var cmd = new Command(Dummy.Program.FilePath) + .WithArguments(["sleep", "00:00:30"]) + | target; + + // Act + var task = cmd.ExecuteBufferedAsync(); + await tcs.Task; + task.Interrupt(); + + // Assert + Assert.DoesNotThrowAsync(async () => await task); + + Assert.Multiple(() => + { + Assert.That(ProcessTree.HasExited(task.ProcessId)); + Assert.That(stdout.ToString(), Does.Contain("Sleeping for")); + Assert.That(stdout.ToString(), Does.Contain("Done.")); + }); + } +} \ 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..294439f --- /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 I_can_create_a_command_with_the_default_configuration() + { + var cmd = new Command("foo"); + + Assert.Multiple(() => + { + Assert.That(cmd.TargetFilePath, Is.EqualTo("foo")); + Assert.That(cmd.Arguments, Is.Empty); + Assert.That(cmd.WorkingDirPath, Is.EqualTo(Directory.GetCurrentDirectory())); + Assert.That(cmd.EnvironmentVariables, Is.Empty); + Assert.That(cmd.Validation, Is.EqualTo(CommandExitBehaviour.ZeroExitCode)); + Assert.That(cmd.StandardInputPipe, Is.EqualTo(PipeSource.Null)); + Assert.That(cmd.StandardOutputPipe, Is.EqualTo(PipeTarget.Null)); + Assert.That(cmd.StandardErrorPipe, Is.EqualTo(PipeTarget.Null)); + }); + } + + [Test] + public void I_can_configure_the_target_file() + { + var cmd = new Command("foo"); + var modified = cmd.WithTargetFile("bar"); + + Assert.Multiple(() => + { + Assert.That(modified.TargetFilePath, Is.EqualTo("bar")); + Assert.That(modified.Arguments, Is.EqualTo(cmd.Arguments)); + Assert.That(modified.WorkingDirPath, Is.EqualTo(cmd.WorkingDirPath)); + Assert.That(modified.EnvironmentVariables, Is.EqualTo(cmd.EnvironmentVariables)); + Assert.That(modified.Validation, Is.EqualTo(cmd.Validation)); + Assert.That(modified.StandardInputPipe, Is.EqualTo(cmd.StandardInputPipe)); + Assert.That(modified.StandardOutputPipe, Is.EqualTo(cmd.StandardOutputPipe)); + Assert.That(modified.StandardErrorPipe, Is.EqualTo(cmd.StandardErrorPipe)); + + Assert.That(cmd.TargetFilePath, Is.Not.EqualTo("bar")); + }); + } + + [Test] + public void I_can_configure_the_command_line_arguments() + { + var cmd = new Command("foo").WithArguments("xxx"); + var modified = cmd.WithArguments("abc def"); + + Assert.Multiple(() => + { + Assert.That(modified.TargetFilePath, Is.EqualTo(cmd.TargetFilePath)); + Assert.That(modified.Arguments, Is.EqualTo("abc def")); + Assert.That(modified.WorkingDirPath, Is.EqualTo(cmd.WorkingDirPath)); + Assert.That(modified.EnvironmentVariables, Is.EqualTo(cmd.EnvironmentVariables)); + Assert.That(modified.Validation, Is.EqualTo(cmd.Validation)); + Assert.That(modified.StandardInputPipe, Is.EqualTo(cmd.StandardInputPipe)); + Assert.That(modified.StandardOutputPipe, Is.EqualTo(cmd.StandardOutputPipe)); + Assert.That(modified.StandardErrorPipe, Is.EqualTo(cmd.StandardErrorPipe)); + + Assert.That(cmd.Arguments, Is.Not.EqualTo("abc def")); + }); + } + + [Test] + public void I_can_configure_the_command_line_arguments_by_passing_an_array() + { + var cmd = new Command("foo").WithArguments("xxx"); + var modified = cmd.WithArguments(["abc", "def"]); + + Assert.Multiple(() => + { + Assert.That(modified.TargetFilePath, Is.EqualTo(cmd.TargetFilePath)); + Assert.That(modified.Arguments, Is.EqualTo("abc def")); + Assert.That(modified.WorkingDirPath, Is.EqualTo(cmd.WorkingDirPath)); + Assert.That(modified.EnvironmentVariables, Is.EqualTo(cmd.EnvironmentVariables)); + Assert.That(modified.Validation, Is.EqualTo(cmd.Validation)); + Assert.That(modified.StandardInputPipe, Is.EqualTo(cmd.StandardInputPipe)); + Assert.That(modified.StandardOutputPipe, Is.EqualTo(cmd.StandardOutputPipe)); + Assert.That(modified.StandardErrorPipe, Is.EqualTo(cmd.StandardErrorPipe)); + + Assert.That(cmd.Arguments, Is.Not.EqualTo("abc def")); + }); + } + + [Test] + public void I_can_configure_the_command_line_arguments_using_a_builder() + { + var cmd = new Command("foo").WithArguments("xxx"); + var modified = cmd.WithArguments(args => args + .Add("-a") + .Add("foo bar") + .Add("\"foo\\\\bar\"") + .Add(3.14) + .Add(["foo", "bar"]) + .Add([-10, 12.12])); + + Assert.Multiple(() => + { + Assert.That(modified.TargetFilePath, Is.EqualTo(cmd.TargetFilePath)); + Assert.That(modified.Arguments, Is.EqualTo("-a \"foo bar\" \"\\\"foo\\\\bar\\\"\" 3.14 foo bar -10 12.12")); + Assert.That(modified.WorkingDirPath, Is.EqualTo(cmd.WorkingDirPath)); + Assert.That(modified.EnvironmentVariables, Is.EqualTo(cmd.EnvironmentVariables)); + Assert.That(modified.Validation, Is.EqualTo(cmd.Validation)); + Assert.That(modified.StandardInputPipe, Is.EqualTo(cmd.StandardInputPipe)); + Assert.That(modified.StandardOutputPipe, Is.EqualTo(cmd.StandardOutputPipe)); + Assert.That(modified.StandardErrorPipe, Is.EqualTo(cmd.StandardErrorPipe)); + + Assert.That(cmd.Arguments, Is.Not.EqualTo("-a \"foo bar\" \"\\\"foo\\\\bar\\\"\" 3.14 foo bar -10 12.12")); + }); + } + + [Test] + public void I_can_configure_the_working_directory() + { + var cmd = new Command("foo").WithWorkingDirectory("xxx"); + var modified = cmd.WithWorkingDirectory("new"); + + Assert.Multiple(() => + { + Assert.That(modified.TargetFilePath, Is.EqualTo(cmd.TargetFilePath)); + Assert.That(modified.Arguments, Is.EqualTo(cmd.Arguments)); + Assert.That(modified.WorkingDirPath, Is.EqualTo("new")); + Assert.That(modified.EnvironmentVariables, Is.EqualTo(cmd.EnvironmentVariables)); + Assert.That(modified.Validation, Is.EqualTo(cmd.Validation)); + Assert.That(modified.StandardInputPipe, Is.EqualTo(cmd.StandardInputPipe)); + Assert.That(modified.StandardOutputPipe, Is.EqualTo(cmd.StandardOutputPipe)); + Assert.That(modified.StandardErrorPipe, Is.EqualTo(cmd.StandardErrorPipe)); + + Assert.That(cmd.WorkingDirPath, Is.Not.EqualTo("new")); + }); + } + + [Test] + public void I_can_configure_the_environment_variables() + { + var cmd = new Command("foo").WithEnvironmentVariables(e => e.Set("xxx", "xxx")); + var vars = new Dictionary { ["name"] = "value", ["key"] = "door" }; + var modified = cmd.WithEnvironmentVariables(vars); + + Assert.Multiple(() => + { + Assert.That(modified.TargetFilePath, Is.EqualTo(cmd.TargetFilePath)); + Assert.That(modified.Arguments, Is.EqualTo(cmd.Arguments)); + Assert.That(modified.WorkingDirPath, Is.EqualTo(cmd.WorkingDirPath)); + Assert.That(modified.EnvironmentVariables, Is.EqualTo(vars)); + Assert.That(modified.Validation, Is.EqualTo(cmd.Validation)); + Assert.That(modified.StandardInputPipe, Is.EqualTo(cmd.StandardInputPipe)); + Assert.That(modified.StandardOutputPipe, Is.EqualTo(cmd.StandardOutputPipe)); + Assert.That(modified.StandardErrorPipe, Is.EqualTo(cmd.StandardErrorPipe)); + + Assert.That(cmd.EnvironmentVariables, Is.Not.EqualTo(vars)); + }); + } + + [Test] + public void I_can_configure_the_environment_variables_using_a_builder() + { + var cmd = new Command("foo").WithEnvironmentVariables(e => e.Set("xxx", "xxx")); + var modified = cmd.WithEnvironmentVariables(env => env + .Set("name", "value") + .Set("key", "door") + .Set(new Dictionary { ["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 I_can_configure_the_result_validation_strategy() + { + var cmd = new Command("foo").WithValidation(CommandExitBehaviour.ZeroExitCode); + var modified = cmd.WithValidation(CommandExitBehaviour.None); + + Assert.Multiple(() => + { + Assert.That(modified.TargetFilePath, Is.EqualTo(cmd.TargetFilePath)); + Assert.That(modified.Arguments, Is.EqualTo(cmd.Arguments)); + Assert.That(modified.WorkingDirPath, Is.EqualTo(cmd.WorkingDirPath)); + Assert.That(modified.EnvironmentVariables, Is.EqualTo(cmd.EnvironmentVariables)); + Assert.That(modified.Validation, Is.EqualTo(CommandExitBehaviour.None)); + Assert.That(modified.StandardInputPipe, Is.EqualTo(cmd.StandardInputPipe)); + Assert.That(modified.StandardOutputPipe, Is.EqualTo(cmd.StandardOutputPipe)); + Assert.That(modified.StandardErrorPipe, Is.EqualTo(cmd.StandardErrorPipe)); + + Assert.That(cmd.Validation, Is.Not.EqualTo(CommandExitBehaviour.None)); + }); + } + + [Test] + public void I_can_configure_the_stdin_pipe() + { + var cmd = new Command("foo").WithStandardInputPipe(PipeSource.Null); + var pipeSource = PipeSource.FromStream(Stream.Null); + var modified = cmd.WithStandardInputPipe(pipeSource); + + Assert.Multiple(() => + { + Assert.That(modified.TargetFilePath, Is.EqualTo(cmd.TargetFilePath)); + Assert.That(modified.Arguments, Is.EqualTo(cmd.Arguments)); + Assert.That(modified.WorkingDirPath, Is.EqualTo(cmd.WorkingDirPath)); + Assert.That(modified.EnvironmentVariables, Is.EqualTo(cmd.EnvironmentVariables)); + Assert.That(modified.Validation, Is.EqualTo(cmd.Validation)); + Assert.That(modified.StandardInputPipe, Is.EqualTo(pipeSource)); + Assert.That(modified.StandardOutputPipe, Is.EqualTo(cmd.StandardOutputPipe)); + Assert.That(modified.StandardErrorPipe, Is.EqualTo(cmd.StandardErrorPipe)); + + Assert.That(cmd.StandardInputPipe, Is.Not.EqualTo(pipeSource)); + }); + } + + [Test] + public void I_can_configure_the_stdout_pipe() + { + var cmd = new Command("foo").WithStandardOutputPipe(PipeTarget.Null); + var pipeTarget = PipeTarget.ToStream(Stream.Null); + var modified = cmd.WithStandardOutputPipe(pipeTarget); + + Assert.Multiple(() => + { + Assert.That(modified.TargetFilePath, Is.EqualTo(cmd.TargetFilePath)); + Assert.That(modified.Arguments, Is.EqualTo(cmd.Arguments)); + Assert.That(modified.WorkingDirPath, Is.EqualTo(cmd.WorkingDirPath)); + Assert.That(modified.EnvironmentVariables, Is.EqualTo(cmd.EnvironmentVariables)); + Assert.That(modified.Validation, Is.EqualTo(cmd.Validation)); + Assert.That(modified.StandardInputPipe, Is.EqualTo(cmd.StandardInputPipe)); + Assert.That(modified.StandardOutputPipe, Is.EqualTo(pipeTarget)); + Assert.That(modified.StandardErrorPipe, Is.EqualTo(cmd.StandardErrorPipe)); + + Assert.That(cmd.StandardOutputPipe, Is.Not.EqualTo(pipeTarget)); + }); + } + + [Test] + public void I_can_configure_the_stderr_pipe() + { + var cmd = new Command("foo").WithStandardErrorPipe(PipeTarget.Null); + var pipeTarget = PipeTarget.ToStream(Stream.Null); + var modified = cmd.WithStandardErrorPipe(pipeTarget); + + Assert.Multiple(() => + { + Assert.That(modified.TargetFilePath, Is.EqualTo(cmd.TargetFilePath)); + Assert.That(modified.Arguments, Is.EqualTo(cmd.Arguments)); + Assert.That(modified.WorkingDirPath, Is.EqualTo(cmd.WorkingDirPath)); + Assert.That(modified.EnvironmentVariables, Is.EqualTo(cmd.EnvironmentVariables)); + Assert.That(modified.Validation, Is.EqualTo(cmd.Validation)); + Assert.That(modified.StandardInputPipe, Is.EqualTo(cmd.StandardInputPipe)); + Assert.That(modified.StandardOutputPipe, Is.EqualTo(cmd.StandardOutputPipe)); + Assert.That(modified.StandardErrorPipe, Is.EqualTo(pipeTarget)); + + Assert.That(cmd.StandardErrorPipe, Is.Not.EqualTo(pipeTarget)); + }); + } +} \ 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..6501fe3 --- /dev/null +++ b/src/Process.Tests/ExecuteTests.cs @@ -0,0 +1,137 @@ +using System.ComponentModel; + +using Geekeey.Extensions.Process.Buffered; + +namespace Geekeey.Extensions.Process.Tests; + +[TestFixture] +internal sealed class ExecuteTests +{ + [Test] + public async Task I_can_execute_a_command_and_get_the_exit_code_and_execution_time() + { + // Arrange + var cmd = new Command(Dummy.Program.FilePath) + .WithArguments(["echo"]); + + // Act + var result = await cmd.ExecuteAsync(); + + Assume.That(result.ExitCode, Is.EqualTo(0)); + + // Assert + Assert.Multiple(() => + { + Assert.That(result.ExitCode, Is.EqualTo(0)); + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.RunTime, Is.GreaterThan(TimeSpan.Zero)); + }); + } + + [Test] + public async Task I_can_execute_a_command_and_get_the_associated_process_id() + { + // Arrange + var cmd = new Command(Dummy.Program.FilePath) + .WithArguments(["echo"]); + + // Act + var task = cmd.ExecuteAsync(); + + // Assert + Assert.That(task.ProcessId, Is.Not.EqualTo(0)); + + await task; + } + + [Test] + public async Task I_can_execute_a_command_with_a_configured_awaiter() + { + // Arrange + var cmd = new Command(Dummy.Program.FilePath) + .WithArguments(["echo"]); + + // Act + Assert + await cmd.ExecuteAsync().ConfigureAwait(false); + } + + [Test] + public Task I_can_try_to_execute_a_command_and_get_an_error_if_the_target_file_does_not_exist() + { + // Arrange + var cmd = new Command("some_exe_with_does_not_exits"); + + // Act + Assert + Assert.ThrowsAsync(async () => await cmd.ExecuteAsync()); + return Task.CompletedTask; + } + + [Test] + public async Task I_can_execute_a_command_with_a_custom_working_directory() + { + // Arrange + using var dir = TestTempDirectory.Create(); + + var cmd = new Command(Dummy.Program.FilePath) + .WithArguments("cwd") + .WithWorkingDirectory(dir.Path); + + // Act + var result = await cmd.ExecuteBufferedAsync(); + + Assume.That(result.ExitCode, Is.EqualTo(0)); + + // Assert + var lines = result.StandardOutput.Split(Environment.NewLine); + Assert.That(lines, Is.SupersetOf(new[] { dir.Path })); + } + + [Test] + public async Task I_can_execute_a_command_with_additional_environment_variables() + { + // Arrange + var cmd = new Command(Dummy.Program.FilePath) + .WithArguments(["env", "foo", "bar"]) + .WithEnvironmentVariables(env => env + .Set("foo", "hello") + .Set("bar", "world")); + + // Act + var result = await cmd.ExecuteBufferedAsync(); + + Assume.That(result.ExitCode, Is.EqualTo(0)); + + // Assert + var lines = result.StandardOutput.Split(Environment.NewLine); + Assert.That(lines, Is.SupersetOf(new[] { "hello", "world" })); + } + + [Test] + public async Task I_can_execute_a_command_with_some_environment_variables_overwritten() + { + // Arrange + var key = Guid.NewGuid(); + var variableToKeep = $"GKY_TEST_KEEP_{key}"; + var variableToOverwrite = $"GKY_TEST_OVERWRITE_{key}"; + var variableToUnset = $"GKY_TEST_UNSET_{key}"; + + using var _a = (TestEnvironment.Create(variableToKeep, "keep")); + using var _b = (TestEnvironment.Create(variableToOverwrite, "overwrite")); + using var _c = (TestEnvironment.Create(variableToUnset, "unset")); + + var cmd = new Command(Dummy.Program.FilePath) + .WithArguments(["env", variableToKeep, variableToOverwrite, variableToUnset]) + .WithEnvironmentVariables(env => env + .Set(variableToOverwrite, "overwritten") + .Set(variableToUnset, null)); + + // Act + var result = await cmd.ExecuteBufferedAsync(); + + Assume.That(result.ExitCode, Is.EqualTo(0)); + + // Assert + var lines = result.StandardOutput.Split(Environment.NewLine); + Assert.That(lines, Is.SupersetOf(new[] { "keep", "overwritten" })); + } +} \ No newline at end of file diff --git a/src/Process.Tests/Fixtures/ProcessTree.cs b/src/Process.Tests/Fixtures/ProcessTree.cs new file mode 100644 index 0000000..99d64ba --- /dev/null +++ b/src/Process.Tests/Fixtures/ProcessTree.cs @@ -0,0 +1,18 @@ +namespace Geekeey.Extensions.Process.Tests; + +internal static class ProcessTree +{ + public static bool HasExited(int id) + { + try + { + using var process = System.Diagnostics.Process.GetProcessById(id); + return process.HasExited; + } + catch + { + // GetProcessById throws if the process can not be found, which means it is not running! + return true; + } + } +} \ 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/LineBreakTests.cs b/src/Process.Tests/LineBreakTests.cs new file mode 100644 index 0000000..4ae6619 --- /dev/null +++ b/src/Process.Tests/LineBreakTests.cs @@ -0,0 +1,77 @@ +namespace Geekeey.Extensions.Process.Tests; + +[TestFixture] +internal sealed class LineBreakTests +{ + private static Command Echo() + => new Command(Dummy.Program.FilePath) + .WithArguments("echo-stdin"); + + [Test] + public async Task I_can_execute_a_command_and_split_the_stdout_by_newline() + { + // Arrange + const string data = "Foo\nBar\nBaz"; + + var stdOutLines = new List(); + + var cmd = data | Echo() | stdOutLines.Add; + + // Act + await cmd.ExecuteAsync(); + + // Assert + Assert.That(stdOutLines, Is.EquivalentTo(new[] { "Foo", "Bar", "Baz" })); + } + + [Test] + public async Task I_can_execute_a_command_and_split_the_stdout_by_caret_return() + { + // Arrange + const string data = "Foo\rBar\rBaz"; + + var stdOutLines = new List(); + + var cmd = data | Echo() | stdOutLines.Add; + + // Act + await cmd.ExecuteAsync(); + + // Assert + Assert.That(stdOutLines, Is.EquivalentTo(new[] { "Foo", "Bar", "Baz" })); + } + + [Test] + public async Task I_can_execute_a_command_and_split_the_stdout_by_caret_return_followed_by_newline() + { + // Arrange + const string data = "Foo\r\nBar\r\nBaz"; + + var stdOutLines = new List(); + + var cmd = data | Echo() | stdOutLines.Add; + + // Act + await cmd.ExecuteAsync(); + + // Assert + Assert.That(stdOutLines, Is.EquivalentTo(new[] { "Foo", "Bar", "Baz" })); + } + + [Test] + public async Task I_can_execute_a_command_and_split_the_stdout_by_newline_while_including_empty_lines() + { + // Arrange + const string data = "Foo\r\rBar\n\nBaz"; + + var stdOutLines = new List(); + + var cmd = data | Echo() | stdOutLines.Add; + + // Act + await cmd.ExecuteAsync(); + + // Assert + Assert.That(stdOutLines, Is.EquivalentTo(new[] { "Foo", "", "Bar", "", "Baz" })); + } +} \ 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..40e93da --- /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 I_can_execute_a_command_on_an_executable_using_its_short_name() + { + // Arrange + var cmd = new Command("dotnet") + .WithArguments("--version"); + + // Act + var result = await cmd.ExecuteBufferedAsync(); + + // Assert + Assert.Multiple(() => + { + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.StandardOutput.Trim(), Does.Match(@"^\d+\.\d+\.\d+$")); + }); + } + + [Test] + [Platform("win")] + public async Task I_can_execute_a_command_on_a_script_using_its_short_name() + { + // Arrange + using var dir = TestTempDirectory.Create(); + await File.WriteAllTextAsync(Path.Combine(dir.Path, "script.cmd"), "@echo hi"); + + using var _1 = TestEnvironment.ExtendPath(dir.Path); + var cmd = new Command("script"); + + // Act + var result = await cmd.ExecuteBufferedAsync(); + + // Assert + // Assert + Assert.Multiple(() => + { + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.StandardOutput.Trim(), Is.EqualTo("hi")); + }); + } +} \ 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..f75bb56 --- /dev/null +++ b/src/Process.Tests/PipingTests.cs @@ -0,0 +1,520 @@ +using System.Text; + +using Geekeey.Extensions.Process.Buffered; + +namespace Geekeey.Extensions.Process.Tests; + +[TestFixture] +internal sealed class PipingTests +{ + #region Stdin + + [Test] + public async Task I_can_execute_a_command_and_pipe_the_stdin_from_an_async_anonymous_source() + { + // Arrange + var source = PipeSource.Create(async (destination, cancellationToken) + => await destination.WriteAsync("Hello World!"u8.ToArray(), cancellationToken)); + + var cmd = + source | new Command(Dummy.Program.FilePath) + .WithArguments("echo-stdin"); + + // Act + var result = await cmd.ExecuteBufferedAsync(); + + // Assert + Assert.That(result.StandardOutput.Trim(), Is.EqualTo("Hello World!")); + } + + [Test] + public async Task I_can_execute_a_command_and_pipe_the_stdin_from_a_sync_anonymous_source() + { + // Arrange + var source = PipeSource.Create(destination + => destination.Write("Hello World!"u8.ToArray())); + + var cmd = + source | new Command(Dummy.Program.FilePath) + .WithArguments("echo-stdin"); + + // Act + var result = await cmd.ExecuteBufferedAsync(); + + // Assert + Assert.That(result.StandardOutput.Trim(), Is.EqualTo("Hello World!")); + } + + [Test] + public async Task I_can_execute_a_command_and_pipe_the_stdin_from_a_stream() + { + // Arrange + using var source = new MemoryStream("Hello World!"u8.ToArray()); + + var cmd = + source | new Command(Dummy.Program.FilePath) + .WithArguments("echo-stdin"); + + // Act + var result = await cmd.ExecuteBufferedAsync(); + + // Assert + Assert.That(result.StandardOutput.Trim(), Is.EqualTo("Hello World!")); + } + + [Test] + public async Task I_can_execute_a_command_and_pipe_the_stdin_from_memory() + { + // Arrange + var data = new ReadOnlyMemory("Hello World!"u8.ToArray()); + + var cmd = + data | new Command(Dummy.Program.FilePath) + .WithArguments("echo-stdin"); + + // Act + var result = await cmd.ExecuteBufferedAsync(); + + // Assert + Assert.That(result.StandardOutput.Trim(), Is.EqualTo("Hello World!")); + } + + [Test] + public async Task I_can_execute_a_command_and_pipe_the_stdin_from_a_byte_array() + { + // Arrange + var data = "Hello World!"u8.ToArray(); + + var cmd = + data | new Command(Dummy.Program.FilePath) + .WithArguments("echo-stdin"); + + // Act + var result = await cmd.ExecuteBufferedAsync(); + + // Assert + Assert.That(result.StandardOutput.Trim(), Is.EqualTo("Hello World!")); + } + + [Test] + public async Task I_can_execute_a_command_and_pipe_the_stdin_from_a_string() + { + // Arrange + var data = "Hello World!"; + + var cmd = + data | new Command(Dummy.Program.FilePath) + .WithArguments("echo-stdin"); + + // Act + var result = await cmd.ExecuteBufferedAsync(); + + // Assert + Assert.That(result.StandardOutput.Trim(), Is.EqualTo("Hello World!")); + } + + [Test] + public async Task I_can_execute_a_command_and_pipe_the_stdin_from_another_command() + { + // Arrange + var cmd = + new Command(Dummy.Program.FilePath) + .WithArguments(["generate", "blob", "--length", "100000"]) + | new Command(Dummy.Program.FilePath) + .WithArguments("length"); + + // Act + var result = await cmd.ExecuteBufferedAsync(); + + // Assert + Assert.That(result.StandardOutput.Trim(), Is.EqualTo("100000")); + } + + [Test] + public async Task I_can_execute_a_command_and_pipe_the_stdin_from_a_chain_of_commands() + { + // Arrange + var cmd = + "Hello world" + | new Command(Dummy.Program.FilePath) + .WithArguments("echo-stdin") + | new Command(Dummy.Program.FilePath) + .WithArguments(["echo-stdin", "--length", "5"]) + | new Command(Dummy.Program.FilePath) + .WithArguments("length"); + + // Act + var result = await cmd.ExecuteBufferedAsync(); + + // Assert + Assert.That(result.StandardOutput.Trim(), Is.EqualTo("5")); + } + + #endregion + + #region Stdout + + [Test] + public async Task I_can_execute_a_command_and_pipe_the_stdout_into_an_async_anonymous_target() + { + // Arrange + using var stream = new MemoryStream(); + + var target = PipeTarget.Create(async (origin, cancellationToken) => + // ReSharper disable once AccessToDisposedClosure + await origin.CopyToAsync(stream, cancellationToken) + ); + + var cmd = + new Command(Dummy.Program.FilePath) + .WithArguments(["generate", "blob", "--length", "100000"]) | target; + + // Act + await cmd.ExecuteAsync(); + + // Assert + Assert.That(stream.Length, Is.EqualTo(100_000)); + } + + [Test] + public async Task I_can_execute_a_command_and_pipe_the_stdout_into_a_sync_anonymous_target() + { + // Arrange + using var stream = new MemoryStream(); + + var target = PipeTarget.Create(origin => + // ReSharper disable once AccessToDisposedClosure + origin.CopyTo(stream) + ); + + var cmd = + new Command(Dummy.Program.FilePath) + .WithArguments(["generate", "blob", "--length", "100000"]) | target; + + // Act + await cmd.ExecuteAsync(); + + // Assert + Assert.That(stream.Length, Is.EqualTo(100_000)); + } + + [Test] + public async Task I_can_execute_a_command_and_pipe_the_stdout_into_a_stream() + { + // Arrange + using var stream = new MemoryStream(); + + var cmd = + new Command(Dummy.Program.FilePath) + .WithArguments(["generate", "blob", "--length", "100000"]) | stream; + + // Act + await cmd.ExecuteAsync(); + + // Assert + Assert.That(stream.Length, Is.EqualTo(100_000)); + } + + [Test] + public async Task I_can_execute_a_command_and_pipe_the_stdout_into_a_string_builder() + { + // Arrange + var buffer = new StringBuilder(); + + var cmd = + new Command(Dummy.Program.FilePath) + .WithArguments(["echo", "Hello World!"]) | buffer; + + // Act + await cmd.ExecuteAsync(); + + // Assert + Assert.That(buffer.ToString().Trim(), Is.EqualTo("Hello World!")); + } + + [Test] + public async Task I_can_execute_a_command_and_pipe_the_stdout_into_an_async_delegate() + { + // Arrange + var stdOutLinesCount = 0; + + async Task HandleStdOutAsync(string line) + { + await Task.Yield(); + stdOutLinesCount++; + } + + var cmd = + new Command(Dummy.Program.FilePath) + .WithArguments(["generate", "clob", "--lines", "100"]) + | HandleStdOutAsync; + + // Act + await cmd.ExecuteAsync(); + + // Assert + Assert.That(stdOutLinesCount, Is.EqualTo(100)); + } + + [Test] + public async Task I_can_execute_a_command_and_pipe_the_stdout_into_an_async_delegate_with_cancellation() + { + // Arrange + var stdOutLinesCount = 0; + + async Task HandleStdOutAsync(string line, CancellationToken cancellationToken = default) + { + await Task.Delay(1, cancellationToken); + stdOutLinesCount++; + } + + var cmd = + new Command(Dummy.Program.FilePath) + .WithArguments(["generate", "clob", "--lines", "100"]) + | HandleStdOutAsync; + + // Act + await cmd.ExecuteAsync(); + + // Assert + Assert.That(stdOutLinesCount, Is.EqualTo(100)); + } + + [Test] + public async Task I_can_execute_a_command_and_pipe_the_stdout_into_a_sync_delegate() + { + // Arrange + var stdOutLinesCount = 0; + + void HandleStdOut(string line) => stdOutLinesCount++; + + var cmd = + new Command(Dummy.Program.FilePath) + .WithArguments(["generate", "clob", "--lines", "100"]) + | HandleStdOut; + + // Act + await cmd.ExecuteAsync(); + + // Assert + Assert.That(stdOutLinesCount, Is.EqualTo(100)); + } + + #endregion + + #region Stdout & Stderr + + [Test] + public async Task I_can_execute_a_command_and_pipe_the_stdout_and_stderr_into_separate_stream() + { + // Arrange + using var stdOut = new MemoryStream(); + using var stdErr = new MemoryStream(); + + var cmd = + new Command(Dummy.Program.FilePath) + .WithArguments(["generate", "blob", "--target", "all", "--length", "100000"]) + | (stdOut, stdErr); + + // Act + await cmd.ExecuteAsync(); + + // Assert + Assert.Multiple(() => + { + Assert.That(stdOut.Length, Is.EqualTo(100_000)); + Assert.That(stdErr.Length, Is.EqualTo(100_000)); + }); + } + + [Test] + public async Task I_can_execute_a_command_and_pipe_the_stdout_and_stderr_into_string_builder() + { + // Arrange + var stdOutBuffer = new StringBuilder(); + var stdErrBuffer = new StringBuilder(); + + var cmd = + new Command(Dummy.Program.FilePath) + .WithArguments(["echo", "Hello world!", "--target", "all"]) + | (stdOutBuffer, stdErrBuffer); + + // Act + await cmd.ExecuteAsync(); + + // Assert + Assert.Multiple(() => + { + Assert.That(stdOutBuffer.ToString().Trim(), Is.EqualTo("Hello world!")); + Assert.That(stdErrBuffer.ToString().Trim(), Is.EqualTo("Hello world!")); + }); + } + + [Test] + public async Task I_can_execute_a_command_and_pipe_the_stdout_and_stderr_into_separate_async_delegate() + { + // Arrange + var stdOutLinesCount = 0; + var stdErrLinesCount = 0; + + async Task HandleStdOutAsync(string line) + { + await Task.Yield(); + stdOutLinesCount++; + } + + async Task HandleStdErrAsync(string line) + { + await Task.Yield(); + stdErrLinesCount++; + } + + var cmd = + new Command(Dummy.Program.FilePath) + .WithArguments(["generate", "clob", "--target", "all", "--lines", "100"]) + | (HandleStdOutAsync, HandleStdErrAsync); + + // Act + await cmd.ExecuteAsync(); + + // Assert + Assert.Multiple(() => + { + Assert.That(stdOutLinesCount, Is.EqualTo(100)); + Assert.That(stdErrLinesCount, Is.EqualTo(100)); + }); + } + + [Test] + public async Task + I_can_execute_a_command_and_pipe_the_stdout_and_stderr_into_separate_async_delegate_with_cancellation() + { + // Arrange + var stdOutLinesCount = 0; + var stdErrLinesCount = 0; + + async Task HandleStdOutAsync(string line, CancellationToken cancellationToken = default) + { + await Task.Delay(1, cancellationToken); + stdOutLinesCount++; + } + + async Task HandleStdErrAsync(string line, CancellationToken cancellationToken = default) + { + await Task.Delay(1, cancellationToken); + stdErrLinesCount++; + } + + var cmd = + new Command(Dummy.Program.FilePath) + .WithArguments(["generate", "clob", "--target", "all", "--lines", "100"]) + | (HandleStdOutAsync, HandleStdErrAsync); + + // Act + await cmd.ExecuteAsync(); + + // Assert + Assert.Multiple(() => + { + Assert.That(stdOutLinesCount, Is.EqualTo(100)); + Assert.That(stdErrLinesCount, Is.EqualTo(100)); + }); + } + + [Test] + public async Task I_can_execute_a_command_and_pipe_the_stdout_and_stderr_into_separate_sync_delegate() + { + // Arrange + var stdOutLinesCount = 0; + var stdErrLinesCount = 0; + + void HandleStdOut(string line) => stdOutLinesCount++; + void HandleStdErr(string line) => stdErrLinesCount++; + + var cmd = + new Command(Dummy.Program.FilePath) + .WithArguments(["generate", "clob", "--target", "all", "--lines", "100"]) + | (HandleStdOut, HandleStdErr); + + // Act + await cmd.ExecuteAsync(); + + // Assert + Assert.Multiple(() => + { + Assert.That(stdOutLinesCount, Is.EqualTo(100)); + Assert.That(stdErrLinesCount, Is.EqualTo(100)); + }); + } + + #endregion + + [Test] + public async Task I_can_execute_a_command_and_pipe_the_stdout_into_multiple_targets() + { + // Arrange + using var stream1 = new MemoryStream(); + using var stream2 = new MemoryStream(); + using var stream3 = new MemoryStream(); + + var target = PipeTarget.Merge( + PipeTarget.ToStream(stream1), + PipeTarget.ToStream(stream2), + PipeTarget.ToStream(stream3) + ); + + var cmd = new Command(Dummy.Program.FilePath) + .WithArguments(["generate", "blob", "--length", "100000"]) | target; + + // Act + await cmd.ExecuteAsync(); + + // Assert + Assert.Multiple(() => + { + Assert.That(stream1.Length, Is.EqualTo(100_000)); + Assert.That(stream2.Length, Is.EqualTo(100_000)); + Assert.That(stream3.Length, Is.EqualTo(100_000)); + Assert.That(stream1.ToArray(), Is.EqualTo(stream2.ToArray())); + Assert.That(stream2.ToArray(), Is.EqualTo(stream3.ToArray())); + }); + } + + [Test] + public async Task I_can_execute_a_command_and_pipe_the_stdout_into_multiple_hierarchical_targets() + { + // Arrange + using var stream1 = new MemoryStream(); + using var stream2 = new MemoryStream(); + using var stream3 = new MemoryStream(); + using var stream4 = new MemoryStream(); + + var target = PipeTarget.Merge( + PipeTarget.ToStream(stream1), + PipeTarget.Merge( + PipeTarget.ToStream(stream2), + PipeTarget.Merge( + PipeTarget.ToStream(stream3), + PipeTarget.ToStream(stream4)) + ) + ); + + var cmd = new Command(Dummy.Program.FilePath) + .WithArguments(["generate", "blob", "--length", "100000"]) | target; + + // Act + await cmd.ExecuteAsync(); + + // Assert + Assert.Multiple(() => + { + Assert.That(stream1.Length, Is.EqualTo(100_000)); + Assert.That(stream2.Length, Is.EqualTo(100_000)); + Assert.That(stream3.Length, Is.EqualTo(100_000)); + Assert.That(stream4.Length, Is.EqualTo(100_000)); + Assert.That(stream1.ToArray(), Is.EqualTo(stream2.ToArray())); + Assert.That(stream2.ToArray(), Is.EqualTo(stream3.ToArray())); + Assert.That(stream3.ToArray(), Is.EqualTo(stream4.ToArray())); + }); + } +} \ 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..9903999 --- /dev/null +++ b/src/Process.Tests/Process.Tests.csproj @@ -0,0 +1,23 @@ + + + false + true + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Process.Tests/Properties/Assembly.cs b/src/Process.Tests/Properties/Assembly.cs new file mode 100644 index 0000000..2110e87 --- /dev/null +++ b/src/Process.Tests/Properties/Assembly.cs @@ -0,0 +1,2 @@ +[assembly: Parallelizable(ParallelScope.Fixtures)] +// [assembly: LevelOfParallelism(3)] \ No newline at end of file diff --git a/src/Process.Tests/ValidationTests.cs b/src/Process.Tests/ValidationTests.cs new file mode 100644 index 0000000..c32edbd --- /dev/null +++ b/src/Process.Tests/ValidationTests.cs @@ -0,0 +1,65 @@ +using Geekeey.Extensions.Process.Buffered; + +namespace Geekeey.Extensions.Process.Tests; + +[TestFixture] +internal sealed class ValidationTests +{ + private static Command Exit() => new Command(Dummy.Program.FilePath) + .WithArguments(["exit", "1"]); + + [Test] + public void I_can_try_to_execute_a_command_and_get_an_error_if_it_returns_a_non_zero_exit_code() + { + // Arrange + var cmd = Exit(); + + // Act & Assert + var exception = Assert.ThrowsAsync(async () => await cmd.ExecuteAsync()); + + Assert.Multiple(() => + { + Assert.That(exception.ExitCode, Is.EqualTo(1)); + Assert.That(exception.Message, Does.Contain("a non-zero exit code (1)")); + }); + + Console.WriteLine(exception.ToString()); + } + + [Test] + public void I_can_try_to_execute_a_command_with_buffering_and_get_a_detailed_error_if_it_returns_a_non_zero_exit_code() + { + // Arrange + var cmd = Exit(); + + + // Act & Assert + var exception = Assert.ThrowsAsync(async () => await cmd.ExecuteBufferedAsync()); + + Assert.Multiple(() => + { + Assert.That(exception.ExitCode, Is.EqualTo(1)); + Assert.That(exception.Message, Does.Contain("Exit code set to 1")); + }); + + Console.WriteLine(exception.ToString()); + } + + [Test] + public async Task I_can_execute_a_command_without_validating_the_exit_code() + { + // Arrange + var cmd = Exit() + .WithValidation(CommandExitBehaviour.None); + + // Act + var result = await cmd.ExecuteAsync(); + + // Assert + Assert.Multiple(() => + { + Assert.That(result.ExitCode, Is.EqualTo(1)); + Assert.That(result.IsSuccess, Is.False); + }); + } +} \ No newline at end of file diff --git a/src/Process.Win.Notify/Process.Win.Notify.csproj b/src/Process.Win.Notify/Process.Win.Notify.csproj new file mode 100644 index 0000000..ccd7007 --- /dev/null +++ b/src/Process.Win.Notify/Process.Win.Notify.csproj @@ -0,0 +1,8 @@ + + + + Exe + true + + + \ No newline at end of file diff --git a/src/Process.Win.Notify/Program.cs b/src/Process.Win.Notify/Program.cs new file mode 100644 index 0000000..6e736c2 --- /dev/null +++ b/src/Process.Win.Notify/Program.cs @@ -0,0 +1,43 @@ +using System.Globalization; +using System.Runtime.InteropServices; + +namespace Geekeey.Extensions.Process.Win.Notify; + +internal static class Interop +{ + public delegate bool ConsoleCtrlDelegate(uint dwCtrlEvent); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool FreeConsole(); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool AttachConsole(uint dwProcessId); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool SetConsoleCtrlHandler(ConsoleCtrlDelegate? handlerRoutine, bool add); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool GenerateConsoleCtrlEvent(uint dwCtrlEvent, uint dwProcessGroupId); +} + +internal static class Program +{ + public static int Main(string[] args) + { + var processId = uint.Parse(args[0], CultureInfo.InvariantCulture); + var signal = uint.Parse(args[1], CultureInfo.InvariantCulture); + + // detach from the current console, if one is attached to the process. + Interop.FreeConsole(); + + var success = + // attach to the target process group + Interop.AttachConsole(processId) && + // set to ignore signals on self, so the proper exit code can be return + Interop.SetConsoleCtrlHandler(null, true) && + // send the signal to the process group + Interop.GenerateConsoleCtrlEvent(signal, 0); + + return success ? 0 : Marshal.GetLastPInvokeError(); + } +} \ No newline at end of file diff --git a/src/Process.Win.Notify/README.md b/src/Process.Win.Notify/README.md new file mode 100644 index 0000000..dca2378 --- /dev/null +++ b/src/Process.Win.Notify/README.md @@ -0,0 +1,15 @@ +# Process.Win.Notify + +The sending of signals on Windows is difficult so say not the least. If one wishes to send a ctrl signal to a process in +windows, one can't do that. Instead, one can only send a signal to a [process group or console][process-group] . So in +order to use a process group, one must specify the creation of a process group (process tree) when creating a process. +This is currently not possible with the dotnet api ([api-suggestion](https://github.com/dotnet/runtime/issues/44944)) + +An alternative solution is to use the concept of the [console][process-group] and the fact that +the [`GenerateConsoleCtrlEvent`][generate-console-ctrl-event] can also send a signal to all processes which are +connected to a console when providing the `dwProcessGroupId` with the value `0`. +This project create an executable which detaches its own console and attaches itself to the targeted process and sends +the requested signal to the console. + +[process-group]: https://learn.microsoft.com/en-us/windows/console/console-process-groups +[generate-console-ctrl-event]: https://learn.microsoft.com/en-us/windows/console/generateconsolectrlevent \ 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..8d861fe --- /dev/null +++ b/src/Process/Command.Execute.cs @@ -0,0 +1,197 @@ +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, process, 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)); + + // Wait until the process exits normally or gets killed. The timeout is started after the execution is + // forcefully canceled and ensures that we don't wait forever in case the attempt to kill the process failed. + await process.WaitUntilExitAsync(complete.Token); + + // Send the cancellation signal to the stdin pipe since the process has exited and won't need it anymore. + // If the pipe has already been exhausted (most likely), this won't do anything. If the pipe is still trying + // to transfer data, this will cause it to abort. + await stdin.CancelAsync(); + + // Wait until piping is done and propagate exceptions. + await Task.WhenAll(writing, reading); + } + + if (process.ExitCode is 0 || !Validation.HasFlag(CommandExitBehaviour.ZeroExitCode)) + { + return new CommandResult(process.ExitCode, process.StartTime, process.ExitTime); + } + + var message = $"Command execution failed because the underlying process ({process.Name}#{process.Id}) " + + $"returned a non-zero exit code ({process.ExitCode})."; + throw new CommandExecutionException(this, process.ExitCode, message); + } + + private async Task PipeStdOutAsync(ProcessHandle process, CancellationToken cancellationToken = default) + { + await using (process.StandardOutput) + { + await StandardOutputPipe.CopyFromAsync(process.StandardOutput, cancellationToken); + } + } + + private async Task PipeStdErrAsync(ProcessHandle process, CancellationToken cancellationToken = default) + { + await using (process.StandardError) + { + await StandardErrorPipe.CopyFromAsync(process.StandardError, cancellationToken); + } + } + + private async Task PipeStdInAsync(ProcessHandle process, CancellationToken cancellationToken = default) + { + await using (process.StandardInput) + { + try + { + // Some streams do not support cancellation, so we add a fallback that drops the task and returns early. + // This is important with stdin because the process might finish before the pipe has been fully + // exhausted, and we don't want to wait for it. + await StandardInputPipe.CopyToAsync(process.StandardInput, cancellationToken) + .WaitAsync(cancellationToken); + } + // Expect IOException: "The pipe has been ended" (Windows) or "Broken pipe" (Unix). This may happen if the + // process terminated before the pipe has been exhausted. It's not an exceptional situation because the + // process may not need the entire stdin to complete successfully. We also can't rely on process.HasExited + // here because of potential race conditions. + catch (IOException ex) when (ex.GetType() == typeof(IOException)) + { + // Don't catch derived exceptions, such as FileNotFoundException, to avoid false positives. + } + } + } +} \ 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..a24eb66 --- /dev/null +++ b/src/Process/Command.Piping.cs @@ -0,0 +1,157 @@ +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 stream. + /// + [Pure] + public static Command operator |(Command source, Stream target) + => source | PipeTarget.ToStream(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; +} \ No newline at end of file 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..6800b80 --- /dev/null +++ b/src/Process/CommandTask.cs @@ -0,0 +1,71 @@ +using System.Runtime.CompilerServices; + +namespace Geekeey.Extensions.Process; + +/// +/// Represents an asynchronous execution of a command. +/// +public partial class CommandTask : IDisposable +{ + private readonly ProcessHandle _process; + + internal CommandTask(Task task, ProcessHandle process, int processId) + { + Task = task; + _process = process; + ProcessId = processId; + } + + /// + /// Underlying task. + /// + public Task Task { get; } + + /// + /// Underlying process ID. + /// + public int ProcessId { get; } + + internal CommandTask Bind(Func, Task> transform) + => new(transform(Task), _process, ProcessId); + + /// + /// Lazily maps the result of the task using the specified transform. + /// + internal CommandTask Select(Func transform) + => Bind(async task => transform(await task)); + + /// + /// Signals the process with an interrupt request from the keyboard. + /// + public void Interrupt() => _process.Interrupt(); + + /// + /// Immediately stops the associated process and its descendent processes. + /// + public void Kill() => _process.Kill(); + + /// + /// 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..65502d6 --- /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 static bool SendPosixSignal(int pid, PosixSignals signal) + { + return Posix.Kill(pid, (int)signal) is 0; + } + + [SupportedOSPlatform("freebsd")] + [SupportedOSPlatform("linux")] + [SupportedOSPlatform("macOS")] + internal static partial class Posix + { + [LibraryImport("libc", EntryPoint = "kill", SetLastError = true)] + internal static partial int Kill(int pid, int sig); + } + + private enum PosixSignals : int + { + SIGINT = 2, + SIGTERM = 15 + } +} \ 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..b4c548e --- /dev/null +++ b/src/Process/Execution/ProcessHandle.Windows.cs @@ -0,0 +1,79 @@ +using System.Diagnostics; +using System.Runtime.Versioning; +using System.Text; + +namespace Geekeey.Extensions.Process; + +internal partial class ProcessHandle +{ + private const string CSharpSourceText = + """ + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool FreeConsole(); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool AttachConsole(uint p); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool SetConsoleCtrlHandler(uint p, bool a); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool GenerateConsoleCtrlEvent(uint e, uint p); + + public static int NotifyCtrlEvent(uint pid, uint sig) + { + // detach from the current console, if one is attached to the process. + FreeConsole(); + + var success = + // attach to the target process group + AttachConsole(pid) && + // set to ignore signals on self, so the proper exit code can be return + SetConsoleCtrlHandler(0, true) && + // send the signal to the process group + GenerateConsoleCtrlEvent(sig, 0); + + return success ? 0 : Marshal.GetLastWin32Error(); + } + """; + + private const string PowerShellSourceText = + """ + Add-Type -Namespace 'Win32' -Name 'Notify' -Language CSharp -MemberDefinition '{0}'; + exit [Win32.Notify]::NotifyCtrlEvent({1}, {2}) + """; + + [SupportedOSPlatform("windows")] + private static bool SendCtrlSignal(int processId, ConsoleCtrlEvent ctrl) + { + using var process = new System.Diagnostics.Process(); + + var text = string.Format(PowerShellSourceText, CSharpSourceText, processId, (uint)ctrl); + text = Convert.ToBase64String(Encoding.Unicode.GetBytes(text)); + + process.StartInfo = new ProcessStartInfo + { + FileName = "powershell.exe", + Arguments = $"-NoLogo -NoProfile -ExecutionPolicy Bypass -EncodedCommand {text}", + CreateNoWindow = true, + UseShellExecute = false + }; + + if (!process.Start()) + return false; + + if (!process.WaitForExit(30_000)) + return false; + + return process.ExitCode == 0; + } + + internal enum ConsoleCtrlEvent + { + CTRL_C_EVENT = 0, // SIGINT + CTRL_BREAK_EVENT = 1, // SIGQUIT + CTRL_CLOSE_EVENT = 2, // SIGHUP + CTRL_LOGOFF_EVENT = 5, // SIGHUP + CTRL_SHUTDOWN_EVENT = 6, // SIGTERM + } +} \ 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..76c0c5c --- /dev/null +++ b/src/Process/Execution/ProcessHandle.cs @@ -0,0 +1,122 @@ +using System.ComponentModel; +using System.Diagnostics; + +namespace Geekeey.Extensions.Process; + +internal sealed partial class ProcessHandle(ProcessStartInfo startInfo) : IDisposable +{ + private readonly System.Diagnostics.Process _process = new() { StartInfo = startInfo }; + private readonly TaskCompletionSource _exitTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public int Id => _process.Id; + + // we have to keep track of ProcessName ourselves because it becomes inaccessible after the process exits + public string Name => Path.GetFileName(_process.StartInfo.FileName); + + // we are purposely using Stream instead of StreamWriter/StreamReader to push the concerns of + // writing and reading to PipeSource/PipeTarget at the higher level. + public Stream StandardInput => _process.StandardInput.BaseStream; + + public Stream StandardOutput => _process.StandardOutput.BaseStream; + + public Stream StandardError => _process.StandardError.BaseStream; + + // we have to keep track of StartTime ourselves because it becomes inaccessible after the process exits + public DateTimeOffset StartTime { get; private set; } + + // we have to keep track of ExitTime ourselves because it becomes inaccessible after the process exits + public DateTimeOffset ExitTime { get; private set; } + + public int ExitCode => _process.ExitCode; + + public void Start() + { + _process.EnableRaisingEvents = true; + _process.Exited += (_, _) => + { + ExitTime = DateTimeOffset.Now; + _exitTcs.TrySetResult(null); + }; + + try + { + if (!_process.Start()) + { + throw new InvalidOperationException( + $"Failed to start a process with file path '{_process.StartInfo.FileName}'. " + + $"Target file is not an executable or lacks execute permissions."); + } + + StartTime = DateTimeOffset.Now; + } + catch (Win32Exception exception) + { + throw new Win32Exception( + $"Failed to start a process with file path '{_process.StartInfo.FileName}'. " + + $"Target file or working directory doesn't exist, or the provided credentials are invalid.", exception); + } + } + + public void Interrupt() + { + if (TryInterrupt()) return; + + // In case of failure, revert to the default behavior of killing the process. + // Ideally, we should throw an exception here, but this method is called from + // a cancellation callback, which would prevent other callbacks from being called. + Kill(); + Debug.Fail("Failed to send an interrupt signal."); + + return; + + bool TryInterrupt() + { + try + { + if (OperatingSystem.IsWindows()) + { + return SendCtrlSignal(_process.Id, ConsoleCtrlEvent.CTRL_C_EVENT) || + SendCtrlSignal(_process.Id, ConsoleCtrlEvent.CTRL_BREAK_EVENT); + } + + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS() || OperatingSystem.IsFreeBSD()) + { + return SendPosixSignal(_process.Id, PosixSignals.SIGINT) || + SendPosixSignal(_process.Id, PosixSignals.SIGTERM); + } + + // Unsupported platform + return false; + } + catch + { + return false; + } + } + } + + public void Kill() + { + try + { + _process.Kill(true); + } + catch when (_process.HasExited) + { + // The process has exited before we could kill it. This is fine. + } + catch + { + // The process either failed to exit or is in the process of exiting. + // We can't really do anything about it, so just ignore the exception. + Debug.Fail("Failed to kill the process."); + } + } + + public async Task WaitUntilExitAsync(CancellationToken cancellationToken = default) + { + await _exitTcs.Task.WaitAsync(cancellationToken); + } + + public void Dispose() => _process.Dispose(); +} \ 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..cc45f99 --- /dev/null +++ b/src/Process/Process.csproj @@ -0,0 +1,19 @@ + + + true + + + + + true + + + + + + + + + + + \ No newline at end of file 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..5d204c8 --- /dev/null +++ b/src/Process/package-readme.md @@ -0,0 +1,2 @@ +Process is a library for interacting with external command-line interfaces. It provides a convenient model for launching +processes, redirecting input and output streams, awaiting completion, handling cancellation, and more. \ No newline at end of file