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