diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..cb80fe8 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,373 @@ +# GitHub Copilot Instructions for Utilities Project + +## Project Overview + +This is a .NET utility library that provides generally useful C# classes and extensions. The project targets .NET 10 and includes AOT (Ahead-of-Time) compilation support for optimized runtime performance. + +## Code Style and Standards + +### General Guidelines +- Follow C# coding conventions and .NET best practices +- Use meaningful variable and method names +- Keep methods focused and single-purpose +- **Add comprehensive XML documentation comments for ALL public APIs** (required) +- Maintain consistency with existing code style +- Follow the existing patterns in the codebase + +### Formatting Requirements + +**IMPORTANT:** This project uses **Husky.Net** pre-commit hooks that automatically enforce formatting: + +1. **CSharpier** is run first for code formatting +2. **dotnet format** is run second for style enforcement + +#### Formatting Workflow +```bash +# Always format with CSharpier FIRST after editing code +dotnet csharpier . + +# Then run dotnet format to apply .editorconfig rules +dotnet format + +# Verify no changes needed +dotnet format --verify-no-changes +``` + +#### Key Formatting Rules (from .editorconfig) +- **No `var` keyword**: Use explicit types everywhere + ```csharp + // ✅ CORRECT + string text = "hello"; + List numbers = []; + + // ❌ WRONG + var text = "hello"; + var numbers = new List(); + ``` +- **Indentation**: 4 spaces (not tabs) +- **Line endings**: CRLF (Windows) +- **Charset**: UTF-8 +- **Final newline**: Required +- **Trailing whitespace**: Not allowed +- **File-scoped namespaces**: Required +- **Collection expressions**: Preferred `[]` over `new List()` + +#### Pre-Commit Hook +The Husky.Net pre-commit hook automatically runs: +1. `dotnet csharpier .` - Code formatting +2. `dotnet format` - Style enforcement + +**Commits will be rejected if formatting fails!** + +### .NET 10 and AOT Considerations +- The project targets .NET 10 with PublishAot enabled +- Avoid reflection where possible (not AOT-friendly) +- Use source generators instead of runtime reflection when applicable +- Be mindful of trim warnings and compatibility +- Ensure all code is AOT-compatible +- Test AOT compatibility with `dotnet publish` + +## Project Structure + +- **Utilities/**: Main library project containing utility classes + - `CommandLineEx.cs`: Command-line argument parsing utilities + - `ConsoleEx.cs`: Console interaction helpers with color support + - `Download.cs`: HTTP download utilities (sync and async) + - `Extensions.cs`: Extension methods (string compression, logger error handling) + - `FileEx.cs`: File and directory operation utilities with retry logic (sync and async) + - `FileExOptions.cs`: Configuration options for FileEx operations + - `Format.cs`: Byte size formatting utilities (binary and decimal) + - `LogOptions.cs`: Global logging configuration + - `StringCompression.cs`: String compression/decompression using Deflate (sync and async) + - `StringHistory.cs`: Bounded string history buffer + +- **Sandbox/**: Console application for testing and experimentation +- **UtilitiesTests/**: Unit tests using xUnit + +## Testing + +- Use xUnit for all tests +- Follow AAA pattern (Arrange, Act, Assert) +- Test file names should match the class being tested with "Tests" suffix +- Run tests frequently during development +- Maintain good test coverage for public APIs +- **Add tests for all new async methods** +- Consider edge cases and error conditions in tests + +## Dependencies + +- **Serilog**: Logging framework (required for LogOptions and error handling) +- **Microsoft.SourceLink.GitHub**: Source linking for debugging +- **xUnit**: Testing framework +- Keep dependencies minimal and well-justified +- Update package references to latest stable versions when appropriate + +## Common Tasks + +### Building +Run the ".NET Build" task or use: `dotnet build` + +### Publishing +Run the ".NET Publish" task or use: `dotnet publish` + +### Formatting (REQUIRED before commit) +```bash +# Step 1: Format with CSharpier +dotnet csharpier . + +# Step 2: Apply dotnet format rules +dotnet format + +# Step 3: Verify (this is what pre-commit hook checks) +dotnet format --verify-no-changes +``` + +### Running Tests +Use: `dotnet test` + +## Commit Guidelines + +### Pre-Commit Process (Automated by Husky.Net) +The following happens automatically on every commit: +1. ✅ CSharpier formats all C# files +2. ✅ dotnet format applies .editorconfig rules +3. ✅ Commit proceeds if formatting passes +4. ❌ Commit is rejected if formatting fails + +### Manual Pre-Commit Checklist +Before committing, ensure: +- [ ] Code formatted with CSharpier (`dotnet csharpier .`) +- [ ] Style rules applied (`dotnet format`) +- [ ] No formatting issues (`dotnet format --verify-no-changes`) +- [ ] All tests passing (`dotnet test`) +- [ ] Build successful (`dotnet build`) +- [ ] No `var` keywords used +- [ ] XML documentation complete +- [ ] Commit message is clear and descriptive + +### Commit Message Format +Follow conventional commit format: +``` +(): + +[optional body] + +[optional footer] +``` +Examples: +- `feat(download): add async download methods` +- `fix(fileex): correct boundary condition in DeleteDirectory` +- `docs(readme): update async method examples` +- `test(compression): add async compression tests` + +## Package Information + +- **Package ID**: InsaneGenius.Utilities +- **Namespace**: InsaneGenius.Utilities +- **License**: MIT +- **Repository**: https://github.com/ptr727/Utilities +- **Target Framework**: .NET 10 +- **C# Version**: 14.0 +- **Version**: 3.5 (managed by Nerdbank.GitVersioning) + +## When Adding New Features + +1. **Consider AOT compatibility from the start** +2. **Add comprehensive XML documentation** (required for all public APIs) +3. **Create corresponding unit tests** (including async versions) +4. Update README.md if adding significant functionality +5. Ensure backward compatibility when modifying existing APIs +6. Consider performance implications +7. **Use async/await for I/O-bound operations** with proper cancellation token support +8. Handle exceptions appropriately with logging via LogOptions.Logger +9. **Follow existing patterns** (e.g., retry logic, bool return values, exception handling) +10. **Format with CSharpier before running dotnet format** + +## Code Generation Preferences + +### Modern C# Features (C# 14) +- Prefer modern C# language features (pattern matching, records, file-scoped namespaces, etc.) +- Use nullable reference types consistently with `ArgumentNullException.ThrowIfNull()` +- Leverage expression-bodied members where appropriate +- Use collection expressions `[]` for initialization +- Use `extension` keyword for extension methods (inside static class) +- Prefer LINQ for data transformations +- Use primary constructors where appropriate + +### Async/Await Patterns +- **Always use `ConfigureAwait(false)` in library code** +- Provide async versions of I/O-bound methods +- Use `CancellationToken` parameters (default to `default`) +- Use `await using` for async disposal +- Replace blocking calls (`.GetAwaiter().GetResult()`) with proper async +- Use `Task.Delay()` instead of `Thread.Sleep()` in async methods +- Use `Memory` and `Span` for async I/O operations + +### Input Validation +- Use `ArgumentNullException.ThrowIfNull()` for null checks +- Validate parameters early in methods +- Document all exceptions in XML comments + +### Resource Management +- Use `using` statements for proper disposal +- Use `await using` for async disposal +- Avoid explicit `.Close()` calls (using handles it) +- Use `leaveOpen` parameter when appropriate + +### Thread Safety +- Use `Lazy` for thread-safe initialization +- Use `Lock` (C# 13+) instead of `object` for locks +- Avoid static mutable state +- Document thread-safety guarantees + +## Security Considerations + +- Validate all user inputs +- Use secure defaults +- Avoid hardcoding sensitive information +- Follow principle of least privilege +- Use secure random number generation when needed +- Be careful with file path manipulation + +## Performance Guidelines + +- Profile before optimizing +- Be mindful of allocations +- Use `Span` and `Memory` for performance-critical code +- Consider using object pooling for frequently allocated objects +- Use `ValueTask` for async methods that may complete synchronously +- Leverage AOT benefits for startup time and memory usage +- Avoid unnecessary string allocations +- Use `StringBuilder` for string concatenation in loops + +## Common Patterns in This Project + +### Error Handling +```csharp +try +{ + // Operation +} +catch (IOException e) when (LogOptions.Logger.LogAndHandle(e)) +{ + // Retry or return false +} +catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) +{ + return false; +} +``` + +### Retry Logic +- Use `Options.RetryCount` for retry attempts +- Use `Options.Cancel.IsCancellationRequested` for cancellation +- Use `Task.Delay()` for async waits +- Log retry attempts with `LogOptions.Logger.Information()` + +### Method Signatures +- I/O methods return `bool` for success/failure +- Async methods have `Async` suffix +- Async methods include optional `CancellationToken cancellationToken = default` +- Use `out` parameters for additional return values + +### XML Documentation +- Always include `` for all public members +- Document all `` with descriptions +- Document `` with descriptions +- Document all possible `` types +- Use `` for additional context +- Reference other types with `` + +## Anti-Patterns to Avoid + +❌ Sync-over-async: `.GetAwaiter().GetResult()`, `.Wait()`, `.Result` +❌ Missing `ConfigureAwait(false)` in library code +❌ Missing XML documentation on public APIs +❌ Not using `ArgumentNullException.ThrowIfNull()` +❌ Explicit `.Close()` calls when using `using` +❌ Missing cancellation token support in async methods +❌ Race conditions in static initialization +❌ Reflection (not AOT-compatible) +❌ Missing tests for async methods +❌ Using `var` keyword (explicit types required by .editorconfig) +❌ Forgetting to run CSharpier before dotnet format + +## File-Specific Notes + +### Download.cs +- Uses thread-safe `Lazy` initialization +- Provides both sync and async versions +- Returns tuples from async methods for multiple values +- Uses `HttpCompletionOption.ResponseHeadersRead` for efficiency + +### FileEx.cs +- All I/O methods have async versions +- Uses `Options` for retry configuration +- Returns `bool` for success/failure +- Supports cancellation via `Options.Cancel` and method parameter + +### StringCompression.cs +- Supports configurable compression levels +- Has both sync and async versions +- Uses `leaveOpen` for stream management +- Proper error documentation + +### Extensions.cs +- Uses C# 14 `extension` keyword +- Must be inside static class +- Provides extension methods for string compression and logger error handling + +## Development Workflow + +### Making Changes +1. Edit code +2. **Run CSharpier**: `dotnet csharpier .` +3. **Run dotnet format**: `dotnet format` +4. Build: `dotnet build` +5. Test: `dotnet test` +6. Commit (Husky.Net pre-commit hook will verify formatting) + +### Before Committing +```bash +# Format code (REQUIRED ORDER) +dotnet csharpier . +dotnet format + +# Verify +dotnet format --verify-no-changes +dotnet build +dotnet test + +# Commit +git add . +git commit -m "feat: your message" +# Husky.Net hook runs automatically +``` + +### If Pre-Commit Hook Fails +```bash +# Hook will show formatting errors +# Re-run formatters +dotnet csharpier . +dotnet format + +# Try commit again +git commit -m "feat: your message" +``` + +## Tools Required + +- .NET 10 SDK +- CSharpier (installed as dotnet tool) +- Husky.Net (installed as dotnet tool) +- Visual Studio 2022 or VS Code with C# extension + +## EditorConfig Integration + +The project uses `.editorconfig` for style enforcement. Key rules: +- `csharp_style_var_*` = **false** (no var keyword) +- `csharp_style_namespace_declarations` = **file_scoped** +- `csharp_prefer_system_threading_lock` = **true** +- `dotnet_style_prefer_collection_expression` = **when_types_loosely_match** + +Visual Studio and Rider automatically apply these settings. VS Code requires the EditorConfig extension. diff --git a/.github/workflows/BuildPublishPipeline.yml b/.github/workflows/BuildPublishPipeline.yml index c3a530d..f37459e 100644 --- a/.github/workflows/BuildPublishPipeline.yml +++ b/.github/workflows/BuildPublishPipeline.yml @@ -23,7 +23,7 @@ jobs: - name: Setup .NET SDK uses: actions/setup-dotnet@v5 with: - dotnet-version: 9.x + dotnet-version: 10.x # https://github.com/marketplace/actions/checkout - name: Checkout code @@ -45,7 +45,7 @@ jobs: - name: Setup .NET SDK uses: actions/setup-dotnet@v5 with: - dotnet-version: 9.x + dotnet-version: 10.x # https://github.com/marketplace/actions/checkout - name: Checkout code diff --git a/.gitignore b/.gitignore index 45c9a64..d83e0a8 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ .idea .vs -.user + +*.user diff --git a/.husky/pre-commit b/.husky/pre-commit old mode 100644 new mode 100755 diff --git a/.husky/task-runner.json b/.husky/task-runner.json index eb389c7..009e6b3 100644 --- a/.husky/task-runner.json +++ b/.husky/task-runner.json @@ -22,8 +22,7 @@ "style", "--verify-no-changes", "--severity=info", - "--verbosity=detailed", - "--exclude-diagnostics=IDE0055" + "--verbosity=detailed" ], "include": [ "**/*.cs" diff --git a/README.md b/README.md index 248161d..a2157fb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Utilities -Generally useful and not so useful C# .NET utility classes. +Some useful and not so useful C# .NET utility classes. ## Build Status @@ -13,9 +13,14 @@ Code and Pipeline is on [GitHub](https://github.com/ptr727/Utilities)\ Packages published on [NuGet](https://www.nuget.org/packages/InsaneGenius.Utilities/)\ ![NuGet](https://img.shields.io/nuget/v/InsaneGenius.Utilities?logo=nuget) -## Notes +## Version History -- Language tags moved to a dedicated [repo](https://github.com/ptr727/LanguageTags). +- v3.4: + - .NET 10 and AOT support. + - Removed `ProcessEx` process wrapper classes, use [CliWrap](https://github.com/Tyrrrz/CliWrap) instead. + - Code cleanup with help from Copilot. +- v3.3: + - Language tags moved to a dedicated [repo](https://github.com/ptr727/LanguageTags). ## License diff --git a/Sandbox/Program.cs b/Sandbox/Program.cs index ec17b7d..9e8db56 100644 --- a/Sandbox/Program.cs +++ b/Sandbox/Program.cs @@ -4,9 +4,9 @@ using Serilog; // Get the assembly directory -Assembly entryAssembly = Assembly.GetEntryAssembly(); +Assembly? entryAssembly = Assembly.GetEntryAssembly(); Debug.Assert(entryAssembly != null); -string assemblyDirectory = Path.GetDirectoryName(entryAssembly.Location); +string? assemblyDirectory = Path.GetDirectoryName(System.AppContext.BaseDirectory); Debug.Assert(assemblyDirectory != null); string projectDirectory = Path.GetFullPath(Path.Combine(assemblyDirectory, "../../../../")); Log.Logger.Information("Project directory: {ProjectDirectory}", projectDirectory); diff --git a/Sandbox/Sandbox.csproj b/Sandbox/Sandbox.csproj index 73f95c7..08c5bd7 100644 --- a/Sandbox/Sandbox.csproj +++ b/Sandbox/Sandbox.csproj @@ -1,7 +1,13 @@ Exe - net9.0 + net10.0 + true + false + latest + true + true + enable diff --git a/Utilities.sln b/Utilities.sln deleted file mode 100644 index b7a957f..0000000 --- a/Utilities.sln +++ /dev/null @@ -1,67 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.2.32210.308 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Utilities", "Utilities\Utilities.csproj", "{0C347342-311D-4EF8-839C-22D5E2B83345}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{C02ADEE0-B2CB-465B-912B-41D102239980}" - ProjectSection(SolutionItems) = preProject - .gitattributes = .gitattributes - .gitignore = .gitignore - .github\dependabot.yml = .github\dependabot.yml - LICENSE = LICENSE - README.md = README.md - version.json = version.json - EndProjectSection -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UtilitiesTests", "UtilitiesTests\UtilitiesTests.csproj", "{02D62FA1-FB2D-49FD-AA04-96E2B459C9A8}" - ProjectSection(ProjectDependencies) = postProject - {0C347342-311D-4EF8-839C-22D5E2B83345} = {0C347342-311D-4EF8-839C-22D5E2B83345} - EndProjectSection -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sandbox", "Sandbox\Sandbox.csproj", "{23F1E346-172C-4DD6-811E-2980C3022FD0}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Data", "Data", "{7E189F23-48EE-44B2-A102-0FDF27BE9C88}" - ProjectSection(SolutionItems) = preProject - Data\ISO-639-2_utf-8.txt = Data\ISO-639-2_utf-8.txt - Data\iso-639-3.tab = Data\iso-639-3.tab - Data\language-subtag-registry = Data\language-subtag-registry - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "GitHub Actions", "GitHub Actions", "{6AEC3BF4-00CE-4C23-A003-82F12FCBA630}" - ProjectSection(SolutionItems) = preProject - .github\workflows\BuildPublishPipeline.yml = .github\workflows\BuildPublishPipeline.yml - .github\dependabot.yml = .github\dependabot.yml - EndProjectSection -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {0C347342-311D-4EF8-839C-22D5E2B83345}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {0C347342-311D-4EF8-839C-22D5E2B83345}.Debug|Any CPU.Build.0 = Debug|Any CPU - {0C347342-311D-4EF8-839C-22D5E2B83345}.Release|Any CPU.ActiveCfg = Release|Any CPU - {0C347342-311D-4EF8-839C-22D5E2B83345}.Release|Any CPU.Build.0 = Release|Any CPU - {02D62FA1-FB2D-49FD-AA04-96E2B459C9A8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {02D62FA1-FB2D-49FD-AA04-96E2B459C9A8}.Debug|Any CPU.Build.0 = Debug|Any CPU - {02D62FA1-FB2D-49FD-AA04-96E2B459C9A8}.Release|Any CPU.ActiveCfg = Release|Any CPU - {02D62FA1-FB2D-49FD-AA04-96E2B459C9A8}.Release|Any CPU.Build.0 = Release|Any CPU - {23F1E346-172C-4DD6-811E-2980C3022FD0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {23F1E346-172C-4DD6-811E-2980C3022FD0}.Debug|Any CPU.Build.0 = Debug|Any CPU - {23F1E346-172C-4DD6-811E-2980C3022FD0}.Release|Any CPU.ActiveCfg = Release|Any CPU - {23F1E346-172C-4DD6-811E-2980C3022FD0}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {7E189F23-48EE-44B2-A102-0FDF27BE9C88} = {C02ADEE0-B2CB-465B-912B-41D102239980} - {6AEC3BF4-00CE-4C23-A003-82F12FCBA630} = {C02ADEE0-B2CB-465B-912B-41D102239980} - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {F2A0A473-E965-4EF2-ACB0-8276D0127ED2} - EndGlobalSection -EndGlobal diff --git a/Utilities.slnx b/Utilities.slnx new file mode 100644 index 0000000..289d053 --- /dev/null +++ b/Utilities.slnx @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Utilities/CommandLineEx.cs b/Utilities/CommandLineEx.cs index a1230e1..22af498 100644 --- a/Utilities/CommandLineEx.cs +++ b/Utilities/CommandLineEx.cs @@ -2,9 +2,21 @@ namespace InsaneGenius.Utilities; +/// +/// Provides command-line argument parsing utilities. +/// public static class CommandLineEx { - // https://stackoverflow.com/questions/298830/split-string-containing-command-line-parameters-into-string-in-c-sharp + /// + /// Parses a command-line string into individual arguments, respecting quoted strings. + /// + /// The command-line string to parse. + /// An array of parsed arguments. + /// Thrown when is null. + /// + /// This method handles quoted strings correctly, treating spaces within quotes as part of the argument. + /// Based on: https://stackoverflow.com/questions/298830/split-string-containing-command-line-parameters-into-string-in-c-sharp + /// public static string[] ParseArguments(string commandLine) { ArgumentNullException.ThrowIfNull(commandLine); @@ -23,15 +35,24 @@ public static string[] ParseArguments(string commandLine) paramChars[index] = '\n'; } } - return new string(paramChars).Split('\n'); + return new string(paramChars).Split('\n', StringSplitOptions.RemoveEmptyEntries); } + /// + /// Gets the command-line arguments for the current process, excluding the process path. + /// + /// An array of command-line arguments. public static string[] GetCommandLineArgs() { // Split the arguments string[] args = ParseArguments(Environment.CommandLine); // Strip the process path from the list of arguments + if (args.Length == 0) + { + return []; + } + string[] argsEx = new string[args.Length - 1]; Array.Copy(args, 1, argsEx, 0, argsEx.Length); return argsEx; diff --git a/Utilities/ConsoleEx.cs b/Utilities/ConsoleEx.cs index 4a8a8c1..4947dd4 100644 --- a/Utilities/ConsoleEx.cs +++ b/Utilities/ConsoleEx.cs @@ -4,73 +4,139 @@ namespace InsaneGenius.Utilities; +/// +/// Provides enhanced console output utilities with color and timestamp support. +/// public static class ConsoleEx { + /// + /// Writes a line to the console with the specified color and optional timestamp. + /// + /// The foreground color to use. + /// The value to write. + /// + /// This method is thread-safe when used exclusively. Mixing with other console output may result in mismatched colors. + /// Non-empty values are prefixed with a timestamp. + /// public static void WriteLineColor(ConsoleColor color, string value) { - // Locking only works when using this function - // Mixing any other console output may still result in mismatched color output lock (s_writeLineLock) { ConsoleColor oldColor = Console.ForegroundColor; Console.ForegroundColor = color; Console.WriteLine( string.IsNullOrEmpty(value) - ? $"{value}" + ? value : $"{DateTime.Now.ToString(CultureInfo.CurrentCulture)} : {value}" ); Console.ForegroundColor = oldColor; } } + /// + /// Writes a line to the console with the specified color and optional timestamp. + /// + /// The foreground color to use. + /// The value to write. + /// Thrown when is null. public static void WriteLineColor(ConsoleColor color, object value) { ArgumentNullException.ThrowIfNull(value); - WriteLineColor(color, value.ToString()); + WriteLineColor(color, value.ToString() ?? string.Empty); } + /// + /// Writes an error message to the console in red. + /// + /// The error message to write. public static void WriteLineError(string value) => WriteLineColor(ErrorColor, value); + /// + /// Writes an error message to the console in red. + /// + /// The error message to write. + /// Thrown when is null. public static void WriteLineError(object value) { ArgumentNullException.ThrowIfNull(value); - WriteLineError(value.ToString()); + WriteLineError(value.ToString() ?? string.Empty); } + /// + /// Writes an event message to the console in yellow. + /// + /// The event message to write. public static void WriteLineEvent(string value) => WriteLineColor(EventColor, value); + /// + /// Writes an event message to the console in yellow. + /// + /// The event message to write. + /// Thrown when is null. public static void WriteLineEvent(object value) { ArgumentNullException.ThrowIfNull(value); - WriteLineEvent(value.ToString()); + WriteLineEvent(value.ToString() ?? string.Empty); } + /// + /// Writes a tool message to the console in green. + /// + /// The tool message to write. public static void WriteLineTool(string value) => WriteLineColor(ToolColor, value); + /// + /// Writes a tool message to the console in green. + /// + /// The tool message to write. + /// Thrown when is null. public static void WriteLineTool(object value) { ArgumentNullException.ThrowIfNull(value); - WriteLineTool(value.ToString()); + WriteLineTool(value.ToString() ?? string.Empty); } + /// + /// Writes a message to the console in cyan. + /// + /// The message to write. public static void WriteLine(string value) => WriteLineColor(OutputColor, value); + /// + /// Writes a message to the console in cyan. + /// + /// The message to write. + /// Thrown when is null. public static void WriteLine(object value) { ArgumentNullException.ThrowIfNull(value); - WriteLine(value.ToString()); + WriteLine(value.ToString() ?? string.Empty); } private static readonly Lock s_writeLineLock = new(); - // Good looking console colors; Green, Cyan, Red, Magenta, Yellow, White + /// + /// The console color used for tool messages. + /// public const ConsoleColor ToolColor = ConsoleColor.Green; + + /// + /// The console color used for error messages. + /// public const ConsoleColor ErrorColor = ConsoleColor.Red; + + /// + /// The console color used for output messages. + /// public const ConsoleColor OutputColor = ConsoleColor.Cyan; + + /// + /// The console color used for event messages. + /// public const ConsoleColor EventColor = ConsoleColor.Yellow; } diff --git a/Utilities/Download.cs b/Utilities/Download.cs index defdfef..3987553 100644 --- a/Utilities/Download.cs +++ b/Utilities/Download.cs @@ -3,30 +3,50 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Reflection; +using System.Threading; +using System.Threading.Tasks; namespace InsaneGenius.Utilities; +/// +/// Provides HTTP download utilities for files and strings. +/// public static class Download { + private static readonly Lazy s_httpClient = new(CreateHttpClient); + + /// + /// Gets or sets the HTTP client timeout in seconds. Default is 180 seconds. + /// Changes to this property only take effect before the first HTTP request is made. + /// + public static int TimeoutSeconds { get; set; } = 180; + + /// + /// Gets content information (size and last modified time) from a URI. + /// + /// The URI to get content information from. + /// The content size in bytes. + /// The last modified time. + /// True if successful, false otherwise. + /// Thrown when is null. public static bool GetContentInfo(Uri uri, out long size, out DateTime modifiedTime) { + ArgumentNullException.ThrowIfNull(uri); + size = 0; modifiedTime = DateTime.MinValue; try { - // Send GET to URL using HttpResponseMessage httpResponse = GetHttpClient() - .GetAsync(uri) + .GetAsync(uri, HttpCompletionOption.ResponseHeadersRead) .GetAwaiter() .GetResult() .EnsureSuccessStatusCode(); - // Get response - size = (long)httpResponse.Content.Headers.ContentLength; - modifiedTime = (DateTime)httpResponse.Content.Headers.LastModified?.DateTime; + size = httpResponse.Content.Headers.ContentLength ?? 0; + modifiedTime = httpResponse.Content.Headers.LastModified?.DateTime ?? DateTime.MinValue; } - catch (Exception e) - when (LogOptions.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) { return false; } @@ -34,21 +54,57 @@ public static bool GetContentInfo(Uri uri, out long size, out DateTime modifiedT return true; } - public static bool DownloadFile(Uri uri, string fileName) + /// + /// Gets content information (size and last modified time) from a URI asynchronously. + /// + /// The URI to get content information from. + /// The cancellation token. + /// A tuple containing success status, size, and modified time. + /// Thrown when is null. + public static async Task<(bool Success, long Size, DateTime ModifiedTime)> GetContentInfoAsync( + Uri uri, + CancellationToken cancellationToken = default + ) { + ArgumentNullException.ThrowIfNull(uri); + try { - // Get HTTP stream - Stream httpStream = GetHttpClient().GetStreamAsync(uri).GetAwaiter().GetResult(); + using HttpResponseMessage httpResponse = await GetHttpClient() + .GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + _ = httpResponse.EnsureSuccessStatusCode(); + + long size = httpResponse.Content.Headers.ContentLength ?? 0; + DateTime modifiedTime = + httpResponse.Content.Headers.LastModified?.DateTime ?? DateTime.MinValue; + return (true, size, modifiedTime); + } + catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) + { + return (false, 0, DateTime.MinValue); + } + } - // Get file stream - using FileStream fileStream = File.OpenWrite(fileName); + /// + /// Downloads a file from a URI to a local file. + /// + /// The URI to download from. + /// The local file path to save to. + /// True if successful, false otherwise. + /// Thrown when or is null. + public static bool DownloadFile(Uri uri, string fileName) + { + ArgumentNullException.ThrowIfNull(uri); + ArgumentNullException.ThrowIfNull(fileName); - // Write HTTP to file + try + { + using Stream httpStream = GetHttpClient().GetStreamAsync(uri).GetAwaiter().GetResult(); + using FileStream fileStream = File.OpenWrite(fileName); httpStream.CopyTo(fileStream); } - catch (Exception e) - when (LogOptions.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) { return false; } @@ -56,15 +112,56 @@ public static bool DownloadFile(Uri uri, string fileName) return true; } + /// + /// Downloads a file from a URI to a local file asynchronously. + /// + /// The URI to download from. + /// The local file path to save to. + /// The cancellation token. + /// True if successful, false otherwise. + /// Thrown when or is null. + public static async Task DownloadFileAsync( + Uri uri, + string fileName, + CancellationToken cancellationToken = default + ) + { + ArgumentNullException.ThrowIfNull(uri); + ArgumentNullException.ThrowIfNull(fileName); + + try + { + await using Stream httpStream = await GetHttpClient() + .GetStreamAsync(uri, cancellationToken) + .ConfigureAwait(false); + await using FileStream fileStream = File.OpenWrite(fileName); + await httpStream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); + } + catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) + { + return false; + } + + return true; + } + + /// + /// Downloads a string from a URI. + /// + /// The URI to download from. + /// The downloaded string content. + /// True if successful, false otherwise. + /// Thrown when is null. public static bool DownloadString(Uri uri, out string value) { - value = null; + ArgumentNullException.ThrowIfNull(uri); + + value = string.Empty; try { value = GetHttpClient().GetStringAsync(uri).GetAwaiter().GetResult(); } - catch (Exception e) - when (LogOptions.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) { return false; } @@ -72,34 +169,51 @@ public static bool DownloadString(Uri uri, out string value) return true; } - public static HttpClient GetHttpClient() + /// + /// Downloads a string from a URI asynchronously. + /// + /// The URI to download from. + /// The cancellation token. + /// A tuple containing success status and the downloaded string. + /// Thrown when is null. + public static async Task<(bool Success, string Value)> DownloadStringAsync( + Uri uri, + CancellationToken cancellationToken = default + ) { - // TODO: How does static init and synchronization work? - if (!s_httpInit) + ArgumentNullException.ThrowIfNull(uri); + + try { - // Default timeout - s_httpClient.Timeout = TimeSpan.FromSeconds(30); - - // Some services only work if a user agent is set, use the assembly info - // TODO: Set only once, not sure if Add() will keep adding the same value multiple times? - s_httpClient.DefaultRequestHeaders.UserAgent.Add( - new ProductInfoHeaderValue( - Assembly.GetExecutingAssembly().GetName().Name, - Assembly.GetExecutingAssembly().GetName().Version.ToString() - ) - ); - - s_httpInit = true; + string value = await GetHttpClient() + .GetStringAsync(uri, cancellationToken) + .ConfigureAwait(false); + return (true, value); + } + catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) + { + return (false, string.Empty); } - - // Reuse the HTTP client per best practices - // Not able to "easily" use IHttpClientFactory - return s_httpClient; } - public static Uri CreateUri(string url, string userName, string password) + /// + /// Gets the shared HttpClient instance. + /// + /// The HttpClient instance. + public static HttpClient GetHttpClient() => s_httpClient.Value; + + /// + /// Creates a URI with optional username and password credentials. + /// + /// The base URL. + /// The username (optional). + /// The password (optional). + /// The constructed URI. + /// Thrown when is null. + public static Uri CreateUri(string url, string? userName = null, string? password = null) { - // Create Uri + ArgumentNullException.ThrowIfNull(url); + UriBuilder uriBuilder = new(url); if (!string.IsNullOrEmpty(userName)) { @@ -114,6 +228,18 @@ public static Uri CreateUri(string url, string userName, string password) return uriBuilder.Uri; } - private static bool s_httpInit; - private static readonly HttpClient s_httpClient = new(); + private static HttpClient CreateHttpClient() + { + HttpClient client = new() { Timeout = TimeSpan.FromSeconds(TimeoutSeconds) }; + + Assembly assembly = Assembly.GetExecutingAssembly(); + string productName = assembly.GetName().Name ?? "InsaneGenius.Utilities"; + string productVersion = assembly.GetName().Version?.ToString() ?? "1.0.0"; + + client.DefaultRequestHeaders.UserAgent.Add( + new ProductInfoHeaderValue(productName, productVersion) + ); + + return client; + } } diff --git a/Utilities/Extensions.cs b/Utilities/Extensions.cs index cd4eded..4364123 100644 --- a/Utilities/Extensions.cs +++ b/Utilities/Extensions.cs @@ -1,27 +1,96 @@ using System; +using System.IO.Compression; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; using Serilog; namespace InsaneGenius.Utilities; +/// +/// Provides extension methods for string compression and logger error handling. +/// +#pragma warning disable CA1708 // Identifiers should differ by more than case public static class Extensions +#pragma warning restore CA1708 // Identifiers should differ by more than case { - // string.Compress() / string.Decompress() - public static string Compress(this string uncompressedString) => - StringCompression.Compress(uncompressedString); + /// + /// Extension methods for string compression and decompression. + /// + extension(string uncompressedString) + { + /// + /// Compresses the string using Deflate compression. + /// + /// The compression level to use. + /// A Base64-encoded compressed string. + public string Compress(CompressionLevel compressionLevel = CompressionLevel.Optimal) => + StringCompression.Compress(uncompressedString, compressionLevel); - public static string Decompress(this string compressedString) => - StringCompression.Decompress(compressedString); + /// + /// Compresses the string asynchronously using Deflate compression. + /// + /// The compression level to use. + /// The cancellation token. + /// A Base64-encoded compressed string. + public Task CompressAsync( + CompressionLevel compressionLevel = CompressionLevel.Optimal, + CancellationToken cancellationToken = default + ) => + StringCompression.CompressAsync( + uncompressedString, + compressionLevel, + cancellationToken + ); - // catch (Exception e) when (LogOptions.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) - public static bool LogAndPropagate(this ILogger logger, Exception exception, string function) - { - logger.Error(exception, "{Function}", function); - return false; + /// + /// Decompresses a Base64-encoded compressed string. + /// + /// The decompressed string. + public string Decompress() => StringCompression.Decompress(uncompressedString); + + /// + /// Decompresses a Base64-encoded compressed string asynchronously. + /// + /// The cancellation token. + /// The decompressed string. + public Task DecompressAsync(CancellationToken cancellationToken = default) => + StringCompression.DecompressAsync(uncompressedString, cancellationToken); } - public static bool LogAndHandle(this ILogger logger, Exception exception, string function) + /// + /// Extension methods for Serilog ILogger error handling. + /// + extension(ILogger logger) { - logger.Error(exception, "{Function}", function); - return true; + /// + /// Logs an exception and returns false to allow error propagation in catch-when clauses. + /// + /// The exception to log. + /// The function name (automatically captured). + /// Always returns false to allow exception to propagate. + public bool LogAndPropagate( + Exception exception, + [CallerMemberName] string function = "unknown" + ) + { + logger.Error(exception, "{Function}", function); + return false; + } + + /// + /// Logs an exception and returns true to indicate exception handling in catch-when clauses. + /// + /// The exception to log. + /// The function name (automatically captured). + /// Always returns true to indicate exception was handled. + public bool LogAndHandle( + Exception exception, + [CallerMemberName] string function = "unknown" + ) + { + logger.Error(exception, "{Function}", function); + return true; + } } } diff --git a/Utilities/FileEx.cs b/Utilities/FileEx.cs index 42237f4..0c2a960 100644 --- a/Utilities/FileEx.cs +++ b/Utilities/FileEx.cs @@ -2,19 +2,29 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Reflection; using System.Runtime.InteropServices; using System.Security.AccessControl; using System.Security.Principal; +using System.Threading; +using System.Threading.Tasks; namespace InsaneGenius.Utilities; +/// +/// Provides extended file and directory operation utilities with retry logic and cancellation support. +/// public static class FileEx { - // Settings for file operations + /// + /// Settings for file operations including retry behavior and cancellation. + /// public static readonly FileExOptions Options = new(); - // Delete file, and retry in case of failure + /// + /// Deletes a file with retry logic in case of failure. + /// + /// The file to delete. + /// True if successful, false otherwise. public static bool DeleteFile(string fileName) { // Test @@ -43,8 +53,7 @@ public static bool DeleteFile(string fileName) result = true; break; } - catch (IOException e) - when (LogOptions.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (IOException e) when (LogOptions.Logger.LogAndHandle(e)) { // Retry LogOptions.Logger.Information( @@ -55,8 +64,7 @@ public static bool DeleteFile(string fileName) ); _ = Options.RetryWaitForCancel(); } - catch (Exception e) - when (LogOptions.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) { break; } @@ -65,7 +73,65 @@ public static bool DeleteFile(string fileName) return result; } - // Delete directory, and retry in case of failure + /// + /// Deletes a file asynchronously with retry logic in case of failure. + /// + /// The file to delete. + /// The cancellation token. + /// True if successful, false otherwise. + public static async Task DeleteFileAsync( + string fileName, + CancellationToken cancellationToken = default + ) + { + if (Options.TestNoModify) + { + return true; + } + + bool result = false; + for (int retryCount = 0; retryCount < Options.RetryCount; retryCount++) + { + if (Options.Cancel.IsCancellationRequested || cancellationToken.IsCancellationRequested) + { + break; + } + + try + { + if (File.Exists(fileName)) + { + File.Delete(fileName); + } + + result = true; + break; + } + catch (IOException e) when (LogOptions.Logger.LogAndHandle(e)) + { + LogOptions.Logger.Information( + "Deleting ({RetryCount} / {OptionsRetryCount}) : {FileName}", + retryCount, + Options.RetryCount, + fileName + ); + await Task.Delay(Options.RetryWaitTime * 1000, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) + { + break; + } + } + + return result; + } + + /// + /// Deletes a directory with retry logic in case of failure. + /// + /// The directory to delete. + /// True if successful, false otherwise. public static bool DeleteDirectory(string directory) { // Test @@ -94,8 +160,7 @@ public static bool DeleteDirectory(string directory) result = true; break; } - catch (IOException e) - when (LogOptions.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (IOException e) when (LogOptions.Logger.LogAndHandle(e)) { // TODO : Do not retry if folder is not empty, it will never succeed @@ -108,8 +173,61 @@ public static bool DeleteDirectory(string directory) ); _ = Options.RetryWaitForCancel(); } - catch (Exception e) - when (LogOptions.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) + { + break; + } + } + + return result; + } + + /// + /// Deletes a directory asynchronously with retry logic in case of failure. + /// + /// The directory to delete. + /// The cancellation token. + /// True if successful, false otherwise. + public static async Task DeleteDirectoryAsync( + string directory, + CancellationToken cancellationToken = default + ) + { + if (Options.TestNoModify) + { + return true; + } + + bool result = false; + for (int retryCount = 0; retryCount < Options.RetryCount; retryCount++) + { + if (Options.Cancel.IsCancellationRequested || cancellationToken.IsCancellationRequested) + { + break; + } + + try + { + if (Directory.Exists(directory)) + { + Directory.Delete(directory); + } + + result = true; + break; + } + catch (IOException e) when (LogOptions.Logger.LogAndHandle(e)) + { + LogOptions.Logger.Information( + "Deleting ({RetryCount} / {OptionsRetryCount}) : {Directory}", + retryCount, + Options.RetryCount, + directory + ); + await Task.Delay(Options.RetryWaitTime * 1000, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) { break; } @@ -118,7 +236,12 @@ public static bool DeleteDirectory(string directory) return result; } - // Recursively delete the directory and files + /// + /// Recursively deletes a directory and all its contents. + /// + /// The directory to delete. + /// If true, recursively deletes all contents before deleting the directory. + /// True if successful, false otherwise. public static bool DeleteDirectory(string directory, bool recursive) { // Just delete the directory, will fail if not empty @@ -131,7 +254,29 @@ public static bool DeleteDirectory(string directory, bool recursive) return DeleteInsideDirectory(directory) && DeleteDirectory(directory); } - // Rename file, and retry in case of failure + /// + /// Recursively deletes a directory and all its contents asynchronously. + /// + /// The directory to delete. + /// If true, recursively deletes all contents before deleting the directory. + /// The cancellation token. + /// True if successful, false otherwise. + public static async Task DeleteDirectoryAsync( + string directory, + bool recursive, + CancellationToken cancellationToken = default + ) => + !recursive + ? await DeleteDirectoryAsync(directory, cancellationToken).ConfigureAwait(false) + : await DeleteInsideDirectoryAsync(directory, cancellationToken).ConfigureAwait(false) + && await DeleteDirectoryAsync(directory, cancellationToken).ConfigureAwait(false); + + /// + /// Renames or moves a file with retry logic in case of failure. + /// + /// The original file path. + /// The new file path. + /// True if successful, false otherwise. public static bool RenameFile(string originalName, string newName) { // Test @@ -141,10 +286,24 @@ public static bool RenameFile(string originalName, string newName) } // Split path components so we can use them for pretty printing - string originalDirectory = Path.GetDirectoryName(originalName); + string? originalDirectory = Path.GetDirectoryName(originalName); string originalFile = Path.GetFileName(originalName); - string newDirectory = Path.GetDirectoryName(newName); + string? newDirectory = Path.GetDirectoryName(newName); string newFile = Path.GetFileName(newName); + if ( + string.IsNullOrEmpty(originalDirectory) + || string.IsNullOrEmpty(originalFile) + || string.IsNullOrEmpty(newDirectory) + || string.IsNullOrEmpty(newFile) + ) + { + LogOptions.Logger.Error( + "Renaming file failed due to invalid path(s) : {OriginalName} to {NewName}", + originalName, + newName + ); + return false; + } bool result = false; for (int retryCount = 0; retryCount < Options.RetryCount; retryCount++) @@ -168,14 +327,12 @@ public static bool RenameFile(string originalName, string newName) result = true; break; } - catch (FileNotFoundException e) - when (LogOptions.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (FileNotFoundException e) when (LogOptions.Logger.LogAndHandle(e)) { // File not found break; } - catch (IOException e) - when (LogOptions.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (IOException e) when (LogOptions.Logger.LogAndHandle(e)) { // Retry if (originalDirectory.Equals(newDirectory, StringComparison.OrdinalIgnoreCase)) @@ -202,8 +359,7 @@ public static bool RenameFile(string originalName, string newName) _ = Options.RetryWaitForCancel(); } - catch (Exception e) - when (LogOptions.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) { break; } @@ -212,7 +368,110 @@ public static bool RenameFile(string originalName, string newName) return result; } - // Rename or move the folder, and retry in case of failure + /// + /// Renames or moves a file asynchronously with retry logic in case of failure. + /// + /// The original file path. + /// The new file path. + /// The cancellation token. + /// True if successful, false otherwise. + public static async Task RenameFileAsync( + string originalName, + string newName, + CancellationToken cancellationToken = default + ) + { + if (Options.TestNoModify) + { + return true; + } + + string? originalDirectory = Path.GetDirectoryName(originalName); + string originalFile = Path.GetFileName(originalName); + string? newDirectory = Path.GetDirectoryName(newName); + string newFile = Path.GetFileName(newName); + if ( + string.IsNullOrEmpty(originalDirectory) + || string.IsNullOrEmpty(originalFile) + || string.IsNullOrEmpty(newDirectory) + || string.IsNullOrEmpty(newFile) + ) + { + LogOptions.Logger.Error( + "Renaming file failed due to invalid path(s) : {OriginalName} to {NewName}", + originalName, + newName + ); + return false; + } + + bool result = false; + for (int retryCount = 0; retryCount < Options.RetryCount; retryCount++) + { + if (Options.Cancel.IsCancellationRequested || cancellationToken.IsCancellationRequested) + { + break; + } + + try + { + if (File.Exists(newName)) + { + File.Delete(newName); + } + + File.Move(originalName, newName); + result = true; + break; + } + catch (FileNotFoundException e) when (LogOptions.Logger.LogAndHandle(e)) + { + // File not found + break; + } + catch (IOException e) when (LogOptions.Logger.LogAndHandle(e)) + { + // Retry + if (originalDirectory.Equals(newDirectory, StringComparison.OrdinalIgnoreCase)) + { + LogOptions.Logger.Information( + "Renaming ({RetryCount} / {OptionsRetryCount}) : {OriginalDirectory} : {OriginalFile} to {NewFile}", + retryCount, + Options.RetryCount, + originalDirectory, + originalFile, + newFile + ); + } + else + { + LogOptions.Logger.Information( + "Renaming ({RetryCount} / {OptionsRetryCount}) : {OriginalName} to {NewName}", + retryCount, + Options.RetryCount, + originalName, + newName + ); + } + + await Task.Delay(Options.RetryWaitTime * 1000, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) + { + break; + } + } + + return result; + } + + /// + /// Renames or moves a folder with retry logic in case of failure. + /// + /// The original folder path. + /// The new folder path. + /// True if successful, false otherwise. public static bool RenameFolder(string originalName, string newName) { // Test @@ -242,14 +501,12 @@ public static bool RenameFolder(string originalName, string newName) result = true; break; } - catch (FileNotFoundException e) - when (LogOptions.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (FileNotFoundException e) when (LogOptions.Logger.LogAndHandle(e)) { // File not found break; } - catch (IOException e) - when (LogOptions.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (IOException e) when (LogOptions.Logger.LogAndHandle(e)) { // Retry LogOptions.Logger.Information( @@ -261,8 +518,7 @@ public static bool RenameFolder(string originalName, string newName) ); _ = Options.RetryWaitForCancel(); } - catch (Exception e) - when (LogOptions.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) { break; } @@ -271,7 +527,77 @@ public static bool RenameFolder(string originalName, string newName) return result; } - // Recursively delete empty directories in the directory + /// + /// Renames or moves a folder asynchronously with retry logic in case of failure. + /// + /// The original folder path. + /// The new folder path. + /// The cancellation token. + /// True if successful, false otherwise. + public static async Task RenameFolderAsync( + string originalName, + string newName, + CancellationToken cancellationToken = default + ) + { + if (Options.TestNoModify) + { + return true; + } + + bool result = false; + for (int retryCount = 0; retryCount < Options.RetryCount; retryCount++) + { + if (Options.Cancel.IsCancellationRequested || cancellationToken.IsCancellationRequested) + { + break; + } + + try + { + if (Directory.Exists(newName)) + { + _ = await DeleteDirectoryAsync(newName, true, cancellationToken) + .ConfigureAwait(false); + } + + Directory.Move(originalName, newName); + result = true; + break; + } + catch (FileNotFoundException e) when (LogOptions.Logger.LogAndHandle(e)) + { + // File not found + break; + } + catch (IOException e) when (LogOptions.Logger.LogAndHandle(e)) + { + // Retry + LogOptions.Logger.Information( + "Renaming ({RetryCount} / {OptionsRetryCount}) : {OriginalName} to {NewName}", + retryCount, + Options.RetryCount, + originalName, + newName + ); + await Task.Delay(Options.RetryWaitTime * 1000, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) + { + break; + } + } + + return result; + } + + /// + /// Recursively deletes empty directories within a directory. + /// + /// The directory to process. + /// Reference to a counter that will be incremented for each deleted directory. + /// True if successful, false otherwise. public static bool DeleteEmptyDirectories(string directory, ref int deleted) { // Find all directories in this directory, not all subdirectories, we will call recursively @@ -306,7 +632,11 @@ DirectoryInfo dirInfo in parentInfo.EnumerateDirectories( return true; } - // Recursively delete all the files and directories inside the directory + /// + /// Recursively deletes all files and directories inside a directory, but not the directory itself. + /// + /// The directory to clean. + /// True if successful, false otherwise. public static bool DeleteInsideDirectory(string directory) { // Skip if directory does not exist @@ -346,7 +676,66 @@ DirectoryInfo dirInfo in parentInfo.EnumerateDirectories( return true; } - // Try to open the file for read access + /// + /// Recursively deletes all files and directories inside a directory asynchronously, but not the directory itself. + /// + /// The directory to clean. + /// The cancellation token. + /// True if successful, false otherwise. + public static async Task DeleteInsideDirectoryAsync( + string directory, + CancellationToken cancellationToken = default + ) + { + if (!Directory.Exists(directory)) + { + return true; + } + + DirectoryInfo parentInfo = new(directory); + + // Delete all files in this directory + foreach (FileInfo fileInfo in parentInfo.GetFiles()) + { + if (!await DeleteFileAsync(fileInfo.FullName, cancellationToken).ConfigureAwait(false)) + { + return false; + } + } + + // Find all directories in this directory, not all subdirectories, we will call recursively + foreach ( + DirectoryInfo dirInfo in parentInfo.EnumerateDirectories( + "*", + SearchOption.TopDirectoryOnly + ) + ) + { + if ( + !await DeleteInsideDirectoryAsync(dirInfo.FullName, cancellationToken) + .ConfigureAwait(false) + ) + { + return false; + } + + if ( + !await DeleteDirectoryAsync(dirInfo.FullName, cancellationToken) + .ConfigureAwait(false) + ) + { + return false; + } + } + + return true; + } + + /// + /// Checks if a file is readable. + /// + /// The file path to check. + /// True if the file can be opened for reading, false otherwise. public static bool IsFileReadable(string fileName) { try @@ -354,15 +743,18 @@ public static bool IsFileReadable(string fileName) FileInfo fileInfo = new(fileName); return IsFileReadable(fileInfo); } - catch (Exception e) - when (LogOptions.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) { return false; } } - // Wait for the file to become readable - public static bool WaitFileReadAble(string fileName) + /// + /// Waits for a file to become readable with retry logic. + /// + /// The file path to wait for. + /// True if the file became readable, false otherwise. + public static bool WaitFileReadable(string fileName) { bool result = false; for (int retryCount = 0; retryCount < Options.RetryCount; retryCount++) @@ -382,12 +774,10 @@ public static bool WaitFileReadAble(string fileName) FileAccess.Read, FileShare.ReadWrite ); - stream.Close(); result = true; break; } - catch (IOException e) - when (LogOptions.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (IOException e) when (LogOptions.Logger.LogAndHandle(e)) { // Retry LogOptions.Logger.Information( @@ -398,8 +788,59 @@ public static bool WaitFileReadAble(string fileName) ); _ = Options.RetryWaitForCancel(); } - catch (Exception e) - when (LogOptions.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) + { + break; + } + } + + return result; + } + + /// + /// Waits for a file to become readable asynchronously with retry logic. + /// + /// The file path to wait for. + /// The cancellation token. + /// True if the file became readable, false otherwise. + public static async Task WaitFileReadableAsync( + string fileName, + CancellationToken cancellationToken = default + ) + { + bool result = false; + for (int retryCount = 0; retryCount < Options.RetryCount; retryCount++) + { + if (Options.Cancel.IsCancellationRequested || cancellationToken.IsCancellationRequested) + { + break; + } + + // Try to access the file + try + { + FileInfo fileInfo = new(fileName); + await using FileStream stream = fileInfo.Open( + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite + ); + result = true; + break; + } + catch (IOException e) when (LogOptions.Logger.LogAndHandle(e)) + { + // Retry + LogOptions.Logger.Information( + "Waiting for file to become readable ({RetryCount} / {OptionsRetryCount}) : {Name}", + retryCount, + Options.RetryCount, + fileName + ); + await Task.Delay(Options.RetryWaitTime * 1000, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) { break; } @@ -408,23 +849,25 @@ public static bool WaitFileReadAble(string fileName) return result; } - // Try to open the file for read access + /// + /// Checks if a file is readable. + /// + /// The FileInfo object to check. + /// True if the file can be opened for reading, false otherwise. + /// Thrown when is null. public static bool IsFileReadable(FileInfo fileInfo) { ArgumentNullException.ThrowIfNull(fileInfo); try { - // Try to open the file for read access using FileStream stream = fileInfo.Open( FileMode.Open, FileAccess.Read, FileShare.ReadWrite ); - stream.Close(); } - catch (Exception e) - when (LogOptions.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) { return false; } @@ -432,23 +875,25 @@ public static bool IsFileReadable(FileInfo fileInfo) return true; } - // Try to open the file for write access + /// + /// Checks if a file is writeable. + /// + /// The FileInfo object to check. + /// True if the file can be opened for writing, false otherwise. + /// Thrown when is null. public static bool IsFileWriteable(FileInfo fileInfo) { ArgumentNullException.ThrowIfNull(fileInfo); try { - // Try to open the file for write access using FileStream stream = fileInfo.Open( FileMode.Open, FileAccess.Write, FileShare.ReadWrite ); - stream.Close(); } - catch (Exception e) - when (LogOptions.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) { return false; } @@ -456,23 +901,25 @@ public static bool IsFileWriteable(FileInfo fileInfo) return true; } - // Try to open the file for read and write access + /// + /// Checks if a file is both readable and writeable. + /// + /// The FileInfo object to check. + /// True if the file can be opened for reading and writing, false otherwise. + /// Thrown when is null. public static bool IsFileReadWriteable(FileInfo fileInfo) { ArgumentNullException.ThrowIfNull(fileInfo); try { - // Try to open the file for read-write access using FileStream stream = fileInfo.Open( FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite ); - stream.Close(); } - catch (Exception e) - when (LogOptions.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) { return false; } @@ -480,7 +927,11 @@ public static bool IsFileReadWriteable(FileInfo fileInfo) return true; } - // Test if all files in the directory are readable + /// + /// Tests if all files in a directory are readable. + /// + /// The directory to check. + /// True if all files are readable, false otherwise. public static bool AreFilesInDirectoryReadable(string directory) { try @@ -489,14 +940,17 @@ public static bool AreFilesInDirectoryReadable(string directory) DirectoryInfo dirInfo = new(directory); return dirInfo.EnumerateFiles("*.*", SearchOption.TopDirectoryOnly).All(IsFileReadable); } - catch (Exception e) - when (LogOptions.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) { return false; } } - // Create directory if it does not already exists + /// + /// Creates a directory if it does not already exist. + /// + /// The directory path to create. + /// True if successful, false otherwise. public static bool CreateDirectory(string directory) { try @@ -506,8 +960,7 @@ public static bool CreateDirectory(string directory) _ = Directory.CreateDirectory(directory); } } - catch (Exception e) - when (LogOptions.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) { return false; } @@ -515,7 +968,14 @@ public static bool CreateDirectory(string directory) return true; } - // Combine paths and convert relative to absolute + /// + /// Combines three paths and converts the result to an absolute path. + /// + /// The first path component. + /// The second path component. + /// The third path component. + /// The combined absolute path. + /// Thrown when any parameter is null. public static string CombinePath(string path1, string path2, string path3) { ArgumentNullException.ThrowIfNull(path1); @@ -546,6 +1006,13 @@ public static string CombinePath(string path1, string path2, string path3) return path; } + /// + /// Combines two paths and converts the result to an absolute path. + /// + /// The first path component. + /// The second path component. + /// The combined absolute path. + /// Thrown when any parameter is null. public static string CombinePath(string path1, string path2) { ArgumentNullException.ThrowIfNull(path1); @@ -554,8 +1021,14 @@ public static string CombinePath(string path1, string path2) return CombinePath(path1, path2, ""); } - // Enumerate all files and directories in the list of source directories - // The source directories will be added to the directory list + /// + /// Enumerates all files and directories in the specified source directories. + /// + /// The list of source directories to enumerate. + /// Output list of all files found. + /// Output list of all directories found (includes source directories). + /// True if successful, false otherwise. + /// Thrown when is null. public static bool EnumerateDirectories( List sourceList, out List fileList, @@ -589,8 +1062,7 @@ out List directoryList fileList.AddRange(dirInfo.EnumerateFiles("*.*", SearchOption.TopDirectoryOnly)); } } - catch (Exception e) - when (LogOptions.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) { return false; } @@ -598,8 +1070,13 @@ out List directoryList return true; } - // Enumerate all files and directories in the directory - // This directory will be added to the directory list + /// + /// Enumerates all files and directories in the specified directory. + /// + /// The directory to enumerate. + /// Output list of all files found. + /// Output list of all directories found (includes the specified directory). + /// True if successful, false otherwise. public static bool EnumerateDirectory( string directory, out List fileList, @@ -610,8 +1087,16 @@ out List directoryList return EnumerateDirectories(sourceList, out fileList, out directoryList); } - // Reset permissions on the directory - // Grant everyone full control + /// + /// Resets directory permissions to grant everyone full control (Windows only). + /// + /// The directory to reset permissions on. + /// True if successful, false otherwise. + /// + /// ⚠️ WARNING: This grants Everyone full control to the directory. + /// Only use this for temporary directories or when you understand the security implications. + /// This method only executes on Windows platforms. + /// public static bool ResetDirectoryPermissions(string directory) { // Test @@ -640,8 +1125,7 @@ public static bool ResetDirectoryPermissions(string directory) directorySecurity.AddAccessRule(accessRule); directoryInfo.SetAccessControl(directorySecurity); } - catch (Exception e) - when (LogOptions.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) { return false; } @@ -649,17 +1133,33 @@ public static bool ResetDirectoryPermissions(string directory) return true; } - // Prefix the filename with a timestamp + /// + /// Prefixes a file name with the current UTC timestamp. + /// + /// The original file path. + /// The file path with timestamp prefix. public static string TimeStampFileName(string filePath) => TimeStampFileName(filePath, DateTime.UtcNow); + /// + /// Prefixes a file name with a specified timestamp. + /// + /// The original file path. + /// The timestamp to use for prefixing. + /// The file path with timestamp prefix. public static string TimeStampFileName(string filePath, DateTime timeStamp) { - string directory = Path.GetDirectoryName(filePath); + string? directory = Path.GetDirectoryName(filePath); string fileName = $"{timeStamp:yyyyMMddTHHmmss}_{Path.GetFileName(filePath)}"; - return Path.Combine(directory, fileName); + return Path.Combine(directory!, fileName); } + /// + /// Creates a file filled with random data. + /// + /// The file path to create. + /// The size of the file in bytes. + /// True if successful, false otherwise. public static bool CreateRandomFilledFile(string name, long size) { try @@ -697,8 +1197,7 @@ public static bool CreateRandomFilledFile(string name, long size) // Close stream.Close(); } - catch (Exception e) - when (LogOptions.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) { return false; } @@ -706,6 +1205,60 @@ public static bool CreateRandomFilledFile(string name, long size) return true; } + /// + /// Creates a file filled with random data asynchronously. + /// + /// The file path to create. + /// The size of the file in bytes. + /// The cancellation token. + /// True if successful, false otherwise. + public static async Task CreateRandomFilledFileAsync( + string name, + long size, + CancellationToken cancellationToken = default + ) + { + try + { + await using FileStream stream = File.Open( + name, + FileMode.Create, + FileAccess.ReadWrite, + FileShare.ReadWrite + ); + + stream.SetLength(size); + _ = stream.Seek(0, SeekOrigin.Begin); + + const int bufferSize = 2 * Format.MiB; + byte[] buffer = new byte[bufferSize]; + Random rand = new(); + + long remaining = size; + while (remaining > 0) + { + rand.NextBytes(buffer); + long writeSize = Math.Min(remaining, Convert.ToInt64(buffer.Length)); + await stream + .WriteAsync(buffer.AsMemory(0, Convert.ToInt32(writeSize)), cancellationToken) + .ConfigureAwait(false); + remaining -= writeSize; + } + } + catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) + { + return false; + } + + return true; + } + + /// + /// Creates a sparse file of the specified size. + /// + /// The file path to create. + /// The size of the file in bytes. + /// True if successful, false otherwise. public static bool CreateSparseFile(string name, long size) { try @@ -720,12 +1273,35 @@ public static bool CreateSparseFile(string name, long size) // Set length stream.SetLength(size); + } + catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) + { + return false; + } - // Close - stream.Close(); + return true; + } + + /// + /// Creates a sparse file of the specified size asynchronously. + /// + /// The file path to create. + /// The size of the file in bytes. + /// True if successful, false otherwise. + public static async Task CreateSparseFileAsync(string name, long size) + { + try + { + await using FileStream stream = File.Open( + name, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.ReadWrite + ); + + stream.SetLength(size); } - catch (Exception e) - when (LogOptions.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) + catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) { return false; } diff --git a/Utilities/FileExOptions.cs b/Utilities/FileExOptions.cs index 02a6eab..5e41ae5 100644 --- a/Utilities/FileExOptions.cs +++ b/Utilities/FileExOptions.cs @@ -2,19 +2,34 @@ namespace InsaneGenius.Utilities; +/// +/// Configuration options for FileEx operations. +/// public class FileExOptions { - // Test only, do not execute changes + /// + /// Gets or sets a value indicating whether to run in test mode without making actual changes. + /// public bool TestNoModify { get; set; } - // Cancel waiting operations + /// + /// Gets or sets the cancellation token for canceling waiting operations. + /// public CancellationToken Cancel { get; set; } = CancellationToken.None; - // Number of retries + /// + /// Gets or sets the number of retry attempts for failed operations. + /// public int RetryCount { get; set; } = 1; - // Wait time between retries in seconds + /// + /// Gets or sets the wait time between retries in seconds. + /// public int RetryWaitTime { get; set; } = 5; + /// + /// Waits for the specified retry time or until cancellation is requested. + /// + /// True if cancellation was requested, false if the wait time elapsed. public bool RetryWaitForCancel() => Cancel.WaitHandle.WaitOne(RetryWaitTime * 1000); } diff --git a/Utilities/Format.cs b/Utilities/Format.cs index b66dcf6..412fa14 100644 --- a/Utilities/Format.cs +++ b/Utilities/Format.cs @@ -2,43 +2,70 @@ namespace InsaneGenius.Utilities; +/// +/// Provides formatting utilities for byte sizes and size constants. +/// public static class Format { // ReSharper disable InconsistentNaming + + /// Kibibyte (1024 bytes) public const int KiB = 1024; + + /// Mebibyte (1024 KiB) public const int MiB = KiB * 1024; + + /// Gibibyte (1024 MiB) public const int GiB = MiB * 1024; + + /// Tebibyte (1024 GiB) public const long TiB = GiB * 1024L; + + /// Pebibyte (1024 TiB) public const long PiB = TiB * 1024L; + + /// Exbibyte (1024 PiB) public const long EiB = PiB * 1024L; - // public const long ZiB = EiB * 1024L; - // public const long YiB = ZiB * 1024L; + /// Kilobyte (1000 bytes) public const int KB = 1000; + + /// Megabyte (1000 KB) public const int MB = KB * 1000; + + /// Gigabyte (1000 MB) public const int GB = MB * 1000; + + /// Terabyte (1000 GB) public const long TB = GB * 1000L; + + /// Petabyte (1000 TB) public const long PB = TB * 1000L; + + /// Exabyte (1000 PB) public const long EB = PB * 1000L; - // public const long ZB = EB * 1000L; - // public const long YB = ZB * 1000L; // ReSharper restore InconsistentNaming - // https://en.wikipedia.org/wiki/Kibibyte - // https://en.wikipedia.org/wiki/Kilobyte - // https://stackoverflow.com/questions/281640/how-do-i-get-a-human-readable-file-size-in-bytes-abbreviation-using-net - // https://stackoverflow.com/questions/14488796/does-net-provide-an-easy-way-convert-bytes-to-kb-mb-gb-etc + /// + /// Converts bytes to a human-readable string using binary prefixes (KiB, MiB, GiB, etc.). + /// + /// The byte value to convert. + /// The unit suffix to append (default is "B"). + /// A formatted string representing the byte value with appropriate binary prefix. + /// + /// Based on: https://en.wikipedia.org/wiki/Kibibyte + /// public static string BytesToKibi(long value, string units = "B") { - if (value < 0) - { - return "-" + BytesToKibi(Math.Abs(value)); - } - - if (value == 0) + switch (value) { - return $"0{units}"; + case < 0: + return "-" + BytesToKibi(Math.Abs(value), units); + case 0: + return $"0{units}"; + default: + break; } int magnitude = Convert.ToInt32(Math.Floor(Math.Log(value, KiB))); @@ -51,16 +78,25 @@ public static string BytesToKibi(long value, string units = "B") private static readonly string[] s_kibiSuffix = ["", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei"]; + /// + /// Converts bytes to a human-readable string using decimal prefixes (KB, MB, GB, etc.). + /// + /// The byte value to convert. + /// The unit suffix to append (default is "B"). + /// A formatted string representing the byte value with appropriate decimal prefix. + /// + /// Based on: https://en.wikipedia.org/wiki/Kilobyte + /// public static string BytesToKilo(long value, string units = "B") { - if (value < 0) - { - return "-" + BytesToKilo(Math.Abs(value)); - } - - if (value == 0) + switch (value) { - return $"0{units}"; + case < 0: + return "-" + BytesToKilo(Math.Abs(value), units); + case 0: + return $"0{units}"; + default: + break; } int magnitude = Convert.ToInt32(Math.Floor(Math.Log(value, KB))); diff --git a/Utilities/LogOptions.cs b/Utilities/LogOptions.cs index 024e672..5620387 100644 --- a/Utilities/LogOptions.cs +++ b/Utilities/LogOptions.cs @@ -5,8 +5,17 @@ namespace InsaneGenius.Utilities; +/// +/// Provides global logging configuration options. +/// public static class LogOptions { - // Default silent logger + /// + /// Gets or sets the logger instance used throughout the utilities library. + /// Defaults to a silent logger that outputs nothing. + /// + /// + /// Configure this property with your application's logger before using FileEx or Download utilities. + /// public static ILogger Logger { get; set; } = new LoggerConfiguration().CreateLogger(); } diff --git a/Utilities/ProcessEx.cs b/Utilities/ProcessEx.cs deleted file mode 100644 index f378960..0000000 --- a/Utilities/ProcessEx.cs +++ /dev/null @@ -1,241 +0,0 @@ -using System; -using System.Diagnostics; -using System.IO; -using System.Reflection; -using System.Threading.Tasks; - -namespace InsaneGenius.Utilities; - -public class ProcessEx : Process -{ - public int ExecuteEx(string executable, string parameters) => - ExecuteExAsync(executable, parameters).GetAwaiter().GetResult(); - - public async Task ExecuteExAsync(string executable, string parameters) - { - // Start - if (!StartEx(executable, parameters)) - { - return -1; - } - - // Wait for exit - return await WaitForExitAsync().ConfigureAwait(true); - } - - public bool StartEx(string executable, string parameters) - { - // Event handlers - void OutputHandlerEx(object s, DataReceivedEventArgs e) => OutputHandler(e); - void ErrorHandlerEx(object s, DataReceivedEventArgs e) => ErrorHandler(e); - void ExitHandlerEx(object s, EventArgs e) => ExitHandler(); - - // Output and error handlers - StartInfo.RedirectStandardOutput = RedirectOutput; - OutputDataReceived += OutputHandlerEx; - StartInfo.RedirectStandardError = RedirectError; - ErrorDataReceived += ErrorHandlerEx; - - // Exit handler - EnableRaisingEvents = true; - Exited += ExitHandlerEx; - - // Process name and parameters - StartInfo.FileName = executable; - StartInfo.Arguments = parameters; - StartInfo.UseShellExecute = false; - - try - { - // Start process - if (!Start()) - { - return false; - } - } - catch (Exception e) - when (LogOptions.Logger.LogAndHandle(e, MethodBase.GetCurrentMethod()?.Name)) - { - return false; - } - - // Read output and error - if (RedirectOutput) - { - BeginOutputReadLine(); - } - - if (RedirectError) - { - BeginErrorReadLine(); - } - - return true; - } - - public async Task WaitForExitAsync() - { - // Wait for exit handler to be signalled - _ = await _processExitComplete.Task.ConfigureAwait(true); - - // Wait for the process to exit to make sure all output has been written - WaitForExit(); - - // Flush streams - if (OutputStream != null) - { - await OutputStream.FlushAsync(); - } - - if (ErrorStream != null) - { - await ErrorStream.FlushAsync(); - } - - return ExitCode; - } - - protected virtual void OutputHandler(DataReceivedEventArgs e) - { - ArgumentNullException.ThrowIfNull(e); - - if (string.IsNullOrEmpty(e.Data)) - { - return; - } - - // Stream output - OutputStream?.WriteLine(e.Data); - - // String output - OutputString?.AppendLine(e.Data); - - // Console output - if (ConsoleOutput) - { - Console.Out.WriteLine(e.Data); - } - } - - protected virtual void ErrorHandler(DataReceivedEventArgs e) - { - ArgumentNullException.ThrowIfNull(e); - - if (string.IsNullOrEmpty(e.Data)) - { - return; - } - - // Stream output - ErrorStream?.WriteLine(e.Data); - - // String output - ErrorString?.AppendLine(e.Data); - - // Console output - if (ConsoleError) - { - Console.Error.WriteLine(e.Data); - } - } - - protected virtual void ExitHandler() => - // Signal exit - _processExitComplete.TrySetResult(true); - - public static int Execute(string executable, string parameters) - { - // Create new process - using ProcessEx process = new(); - return process.ExecuteEx(executable, parameters); - } - - public static int Execute(string executable, string parameters, bool console) - { - // Console output is ok - if (console) - { - return Execute(executable, parameters); - } - - // Create new process - // Suppress output and error - using ProcessEx process = new() - { - // Redirect and do not output anything - RedirectOutput = true, - ConsoleOutput = false, - RedirectError = true, - ConsoleError = false, - }; - int exitCode = process.ExecuteEx(executable, parameters); - - return exitCode; - } - - public static int Execute( - string executable, - string parameters, - bool console, - int lines, - out string output - ) - { - // Create new process - // Redirect output - using ProcessEx process = new() - { - // Capture output - RedirectOutput = true, - ConsoleOutput = console, - OutputString = new StringHistory(lines, lines), - // If console is false then redirect error but do not output anything - RedirectError = !console, - }; - int exitCode = process.ExecuteEx(executable, parameters); - output = process.OutputString.ToString(); - - return exitCode; - } - - public static int Execute( - string executable, - string parameters, - bool console, - int lines, - out string output, - out string error - ) - { - // Create new process - // Redirect output and error - using ProcessEx process = new() - { - // Capture output and error - RedirectOutput = true, - ConsoleOutput = console, - OutputString = new StringHistory(lines, lines), - RedirectError = true, - ConsoleError = console, - ErrorString = new StringHistory(lines, lines), - }; - int exitCode = process.ExecuteEx(executable, parameters); - output = process.OutputString.ToString(); - error = process.ErrorString.ToString(); - - return exitCode; - } - - public bool RedirectOutput { get; set; } - public bool ConsoleOutput { get; set; } - public StreamWriter OutputStream { get; set; } - public StringHistory OutputString { get; set; } - public bool RedirectError { get; set; } - public bool ConsoleError { get; set; } - public StreamWriter ErrorStream { get; set; } - public StringHistory ErrorString { get; set; } - - private readonly TaskCompletionSource _processExitComplete = new( - TaskCreationOptions.RunContinuationsAsynchronously - ); -} diff --git a/Utilities/StringCompression.cs b/Utilities/StringCompression.cs index 51d0092..a9f0ef4 100644 --- a/Utilities/StringCompression.cs +++ b/Utilities/StringCompression.cs @@ -1,53 +1,135 @@ +using System; using System.IO; using System.IO.Compression; using System.Text; +using System.Threading; +using System.Threading.Tasks; namespace InsaneGenius.Utilities; // https://stackoverflow.com/questions/7343465/compression-decompression-string-with-c-sharp +/// +/// Provides string compression and decompression using Deflate algorithm. +/// public static class StringCompression { - public static string Compress(string uncompressedString) + /// + /// Compresses a string using Deflate compression and returns a Base64-encoded string. + /// + /// The string to compress. + /// The compression level to use. Default is . + /// A Base64-encoded compressed string. + /// Thrown when is null. + public static string Compress( + string uncompressedString, + CompressionLevel compressionLevel = CompressionLevel.Optimal + ) { - byte[] compressedBytes; + ArgumentNullException.ThrowIfNull(uncompressedString); + using MemoryStream uncompressedStream = new(Encoding.UTF8.GetBytes(uncompressedString)); + using MemoryStream compressedStream = new(); + using ( + DeflateStream compressorStream = new( + compressedStream, + compressionLevel, + leaveOpen: true + ) + ) + { + uncompressedStream.CopyTo(compressorStream); + } + byte[] compressedBytes = compressedStream.ToArray(); + + return Convert.ToBase64String(compressedBytes); + } + + /// + /// Compresses a string asynchronously using Deflate compression and returns a Base64-encoded string. + /// + /// The string to compress. + /// The compression level to use. Default is . + /// The cancellation token. + /// A Base64-encoded compressed string. + /// Thrown when is null. + public static async Task CompressAsync( + string uncompressedString, + CompressionLevel compressionLevel = CompressionLevel.Optimal, + CancellationToken cancellationToken = default + ) + { + ArgumentNullException.ThrowIfNull(uncompressedString); + + using MemoryStream uncompressedStream = new(Encoding.UTF8.GetBytes(uncompressedString)); + using MemoryStream compressedStream = new(); + await using ( + DeflateStream compressorStream = new( + compressedStream, + compressionLevel, + leaveOpen: true + ) + ) { - using MemoryStream compressedStream = new(); - { - using DeflateStream compressorStream = new( - compressedStream, - CompressionMode.Compress - ); - { - uncompressedStream.CopyTo(compressorStream); - } - } - compressedBytes = compressedStream.ToArray(); + await uncompressedStream + .CopyToAsync(compressorStream, cancellationToken) + .ConfigureAwait(false); } + byte[] compressedBytes = compressedStream.ToArray(); - return System.Convert.ToBase64String(compressedBytes); + return Convert.ToBase64String(compressedBytes); } + /// + /// Decompresses a Base64-encoded compressed string. + /// + /// The Base64-encoded compressed string to decompress. + /// The decompressed string. + /// Thrown when is null. + /// Thrown when is not a valid Base64 string. + /// Thrown when the compressed data is invalid or corrupted. public static string Decompress(string compressedString) { - byte[] decompressedBytes; - using MemoryStream compressedStream = new( - System.Convert.FromBase64String(compressedString) - ); + ArgumentNullException.ThrowIfNull(compressedString); + + using MemoryStream compressedStream = new(Convert.FromBase64String(compressedString)); + using MemoryStream decompressedStream = new(); + using (DeflateStream decompressorStream = new(compressedStream, CompressionMode.Decompress)) { - using DeflateStream decompressorStream = new( - compressedStream, - CompressionMode.Decompress - ); - { - using MemoryStream decompressedStream = new(); - { - decompressorStream.CopyTo(decompressedStream); - decompressedBytes = decompressedStream.ToArray(); - } - } + decompressorStream.CopyTo(decompressedStream); + } + byte[] decompressedBytes = decompressedStream.ToArray(); + + return Encoding.UTF8.GetString(decompressedBytes); + } + + /// + /// Decompresses a Base64-encoded compressed string asynchronously. + /// + /// The Base64-encoded compressed string to decompress. + /// The cancellation token. + /// The decompressed string. + /// Thrown when is null. + /// Thrown when is not a valid Base64 string. + /// Thrown when the compressed data is invalid or corrupted. + public static async Task DecompressAsync( + string compressedString, + CancellationToken cancellationToken = default + ) + { + ArgumentNullException.ThrowIfNull(compressedString); + + using MemoryStream compressedStream = new(Convert.FromBase64String(compressedString)); + using MemoryStream decompressedStream = new(); + await using ( + DeflateStream decompressorStream = new(compressedStream, CompressionMode.Decompress) + ) + { + await decompressorStream + .CopyToAsync(decompressedStream, cancellationToken) + .ConfigureAwait(false); } + byte[] decompressedBytes = decompressedStream.ToArray(); return Encoding.UTF8.GetString(decompressedBytes); } diff --git a/Utilities/StringHistory.cs b/Utilities/StringHistory.cs index 23a8a35..cf62c87 100644 --- a/Utilities/StringHistory.cs +++ b/Utilities/StringHistory.cs @@ -1,20 +1,42 @@ +using System; using System.Collections.Generic; -using System.Text; namespace InsaneGenius.Utilities; +/// +/// Manages a history of strings with configurable limits on the number of first and last lines to retain. +/// +/// +/// This class is useful for maintaining a bounded history buffer, keeping the first N and last M lines +/// while discarding intermediate content when limits are exceeded. +/// public class StringHistory { + /// + /// Initializes a new instance of the class with no limits. + /// public StringHistory() { } + /// + /// Initializes a new instance of the class with specified limits. + /// + /// Maximum number of first lines to retain. + /// Maximum number of last lines to retain. public StringHistory(int maxFirstLines, int maxLastLines) { MaxFirstLines = maxFirstLines; MaxLastLines = maxLastLines; } + /// + /// Appends a line to the history, respecting the configured limits. + /// + /// The string value to append. + /// Thrown when is null. public void AppendLine(string value) { + ArgumentNullException.ThrowIfNull(value); + // No restrictions if (MaxFirstLines == 0 && MaxLastLines == 0) { @@ -30,6 +52,12 @@ public void AppendLine(string value) return; } + // If MaxLastLines is 0, don't add any more lines after MaxFirstLines + if (MaxLastLines == 0) + { + return; + } + // Restrict last lines if (_lastLines < MaxLastLines) { @@ -43,19 +71,29 @@ public void AppendLine(string value) StringList.Add(value); } - public override string ToString() - { - StringBuilder stringBuilder = new(); - foreach (string item in StringList) - { - _ = stringBuilder.AppendLine(item); - } - - return stringBuilder.ToString(); - } + /// + /// Returns all stored lines as a single string with line breaks. + /// + /// A string containing all stored lines. + public override string ToString() => + string.Join(Environment.NewLine, StringList) + + (StringList.Count > 0 ? Environment.NewLine : string.Empty); + /// + /// Gets or sets the maximum number of first lines to retain. + /// Set to 0 for no limit. + /// public int MaxFirstLines { get; set; } + + /// + /// Gets or sets the maximum number of last lines to retain. + /// Set to 0 for no limit. + /// public int MaxLastLines { get; set; } + + /// + /// Gets the list of stored strings. + /// public List StringList { get; } = []; private int _firstLines; diff --git a/Utilities/Utilities.csproj b/Utilities/Utilities.csproj index 24e5841..946876e 100644 --- a/Utilities/Utilities.csproj +++ b/Utilities/Utilities.csproj @@ -1,7 +1,14 @@ - net9.0 + net10.0 + true + false + latest + true + true + enable true + true Pieter Viljoen Pieter Viljoen Pieter Viljoen @@ -28,18 +35,17 @@ - - - - - - - - - - - - - - + + + + diff --git a/Utilities/Utilities.csproj.user b/Utilities/Utilities.csproj.user deleted file mode 100644 index f58de37..0000000 --- a/Utilities/Utilities.csproj.user +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - Component - - - \ No newline at end of file diff --git a/UtilitiesTests/ConsoleTests.cs b/UtilitiesTests/ConsoleTests.cs index f587685..60c9187 100644 --- a/UtilitiesTests/ConsoleTests.cs +++ b/UtilitiesTests/ConsoleTests.cs @@ -1,13 +1,142 @@ +using System; +using System.IO; using Xunit; namespace InsaneGenius.Utilities.Tests; -// TODO - public class ConsoleTests(UtilitiesTests fixture) : IClassFixture { private readonly UtilitiesTests _fixture = fixture; [Fact] - public void Null() => Assert.False(false); + public void WriteLineColor_WithValidString_ShouldContainMessage() + { + StringWriter output = new(); + Console.SetOut(output); + + ConsoleEx.WriteLineColor(ConsoleColor.Green, "Test message"); + + string result = output.ToString(); + Assert.Contains("Test message", result); + + // Reset console + StreamWriter standardOutput = new(Console.OpenStandardOutput()) { AutoFlush = true }; + Console.SetOut(standardOutput); + } + + [Fact] + public void WriteLineColor_WithEmptyString_ShouldNotThrow() + { + StringWriter output = new(); + Console.SetOut(output); + + ConsoleEx.WriteLineColor(ConsoleColor.Green, string.Empty); + + string result = output.ToString(); + Assert.NotNull(result); + + // Reset console + StreamWriter standardOutput = new(Console.OpenStandardOutput()) { AutoFlush = true }; + Console.SetOut(standardOutput); + } + + [Fact] + public void WriteLineColor_WithNullObject_ShouldThrowArgumentNullException() + { + object? nullValue = null; + + _ = Assert.Throws(() => + ConsoleEx.WriteLineColor(ConsoleColor.Red, nullValue!) + ); + } + + [Fact] + public void WriteLineError_WithString_ShouldContainMessage() + { + StringWriter output = new(); + Console.SetOut(output); + + ConsoleEx.WriteLineError("Error message"); + + string result = output.ToString(); + Assert.Contains("Error message", result); + + // Reset console + StreamWriter standardOutput = new(Console.OpenStandardOutput()) { AutoFlush = true }; + Console.SetOut(standardOutput); + } + + [Fact] + public void WriteLineEvent_WithString_ShouldContainMessage() + { + StringWriter output = new(); + Console.SetOut(output); + + ConsoleEx.WriteLineEvent("Event message"); + + string result = output.ToString(); + Assert.Contains("Event message", result); + + // Reset console + StreamWriter standardOutput = new(Console.OpenStandardOutput()) { AutoFlush = true }; + Console.SetOut(standardOutput); + } + + [Fact] + public void WriteLineTool_WithString_ShouldContainMessage() + { + StringWriter output = new(); + Console.SetOut(output); + + ConsoleEx.WriteLineTool("Tool message"); + + string result = output.ToString(); + Assert.Contains("Tool message", result); + + // Reset console + StreamWriter standardOutput = new(Console.OpenStandardOutput()) { AutoFlush = true }; + Console.SetOut(standardOutput); + } + + [Fact] + public void WriteLine_WithString_ShouldContainMessage() + { + StringWriter output = new(); + Console.SetOut(output); + + ConsoleEx.WriteLine("Output message"); + + string result = output.ToString(); + Assert.Contains("Output message", result); + + // Reset console + StreamWriter standardOutput = new(Console.OpenStandardOutput()) { AutoFlush = true }; + Console.SetOut(standardOutput); + } + + [Theory] + [InlineData(ConsoleColor.Green)] + [InlineData(ConsoleColor.Red)] + [InlineData(ConsoleColor.Yellow)] + [InlineData(ConsoleColor.Cyan)] + public void WriteLineColor_WithDifferentColors_ShouldNotThrow(ConsoleColor color) + { + StringWriter output = new(); + Console.SetOut(output); + + ConsoleEx.WriteLineColor(color, "Test"); + + // Reset console + StreamWriter standardOutput = new(Console.OpenStandardOutput()) { AutoFlush = true }; + Console.SetOut(standardOutput); + } + + [Fact] + public void ColorConstants_ShouldHaveCorrectValues() + { + Assert.Equal(ConsoleColor.Green, ConsoleEx.ToolColor); + Assert.Equal(ConsoleColor.Red, ConsoleEx.ErrorColor); + Assert.Equal(ConsoleColor.Cyan, ConsoleEx.OutputColor); + Assert.Equal(ConsoleColor.Yellow, ConsoleEx.EventColor); + } } diff --git a/UtilitiesTests/DownloadAsyncTests.cs b/UtilitiesTests/DownloadAsyncTests.cs new file mode 100644 index 0000000..f8ece72 --- /dev/null +++ b/UtilitiesTests/DownloadAsyncTests.cs @@ -0,0 +1,115 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace InsaneGenius.Utilities.Tests; + +public class DownloadAsyncTests(UtilitiesTests fixture) : IClassFixture +{ + private readonly UtilitiesTests _fixture = fixture; + + [Fact] + public async Task GetContentInfoAsync_WithValidUri_ShouldReturnSuccess() + { + Uri uri = new( + "https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png" + ); + + (bool success, long size, DateTime _) = await Download.GetContentInfoAsync(uri); + + Assert.True(success); + Assert.True(size > 0); + } + + [Fact] + public async Task DownloadStringAsync_WithValidUri_ShouldReturnContent() + { + Uri uri = new("https://www.google.com"); + + (bool success, string? content) = await Download.DownloadStringAsync(uri); + + Assert.True(success); + Assert.NotEmpty(content); + Assert.Contains("google", content, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task DownloadFileAsync_WithValidUri_ShouldCreateFile() + { + Uri uri = new( + "https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png" + ); + string tempFile = Path.GetTempFileName(); + + try + { + bool result = await Download.DownloadFileAsync(uri, tempFile); + + Assert.True(result); + Assert.True(File.Exists(tempFile)); + Assert.True(new FileInfo(tempFile).Length > 0); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Fact] + public async Task DownloadAsync_WithInvalidUri_ShouldReturnFalse() + { + Uri invalidUri = new("https://thisdoesnotexist123456789.com/file.txt"); + + (bool success, long _, DateTime _) = await Download.GetContentInfoAsync(invalidUri); + + Assert.False(success); + } + + [Fact] + public async Task DownloadAsync_WithCancellation_ShouldRespectCancellation() + { + Uri uri = new("https://httpstat.us/200?sleep=5000"); // Slow endpoint + using CancellationTokenSource cts = new(); + cts.CancelAfter(TimeSpan.FromMilliseconds(100)); // Cancel after 100ms + + (bool Success, string _) = await Download.DownloadStringAsync(uri, cts.Token); + + // Should either throw cancellation or return false due to cancellation + Assert.False(Success); + } + + [Fact] + public async Task DownloadAsync_WithNullUri_ShouldThrowArgumentNullException() + { + Uri? nullUri = null; + + _ = await Assert.ThrowsAsync(async () => + await Download.GetContentInfoAsync(nullUri!) + ); + } + + [Fact] + public void CreateUri_WithCredentials_ShouldIncludeCredentials() + { + string url = "https://example.com/resource"; + string username = "testuser"; + string password = "testpass"; + + Uri result = Download.CreateUri(url, username, password); + + Assert.Contains(username, result.ToString()); + } + + [Fact] + public void CreateUri_WithNullUrl_ShouldThrowArgumentNullException() + { + string? nullUrl = null; + + _ = Assert.Throws(() => Download.CreateUri(nullUrl!)); + } +} diff --git a/UtilitiesTests/ExtensionsTests.cs b/UtilitiesTests/ExtensionsTests.cs new file mode 100644 index 0000000..116ecc3 --- /dev/null +++ b/UtilitiesTests/ExtensionsTests.cs @@ -0,0 +1,274 @@ +using System; +using System.IO.Compression; +using System.Threading; +using System.Threading.Tasks; +using Serilog; +using Serilog.Core; +using Xunit; + +namespace InsaneGenius.Utilities.Tests; + +public class ExtensionsTests(UtilitiesTests fixture) : IClassFixture +{ + private readonly UtilitiesTests _fixture = fixture; + + #region String Compression Extension Tests + + [Fact] + public void StringExtension_Compress_ShouldCompressString() + { + string original = "This is a test string that should be compressed successfully."; + + string compressed = original.Compress(); + + Assert.NotNull(compressed); + Assert.NotEmpty(compressed); + Assert.NotEqual(original, compressed); + } + + [Fact] + public void StringExtension_Decompress_ShouldDecompressString() + { + string original = "This is a test string that should be compressed and then decompressed."; + string compressed = original.Compress(); + + string decompressed = compressed.Decompress(); + + Assert.Equal(original, decompressed); + } + + [Theory] + [InlineData(CompressionLevel.Fastest)] + [InlineData(CompressionLevel.Optimal)] + [InlineData(CompressionLevel.SmallestSize)] + public void StringExtension_Compress_WithDifferentLevels_ShouldWork(CompressionLevel level) + { + string original = "Test string for compression level testing."; + + string compressed = original.Compress(level); + string decompressed = compressed.Decompress(); + + Assert.Equal(original, decompressed); + } + + [Fact] + public async Task StringExtension_CompressAsync_ShouldCompressString() + { + string original = "This is a test string for async compression."; + + string compressed = await original.CompressAsync(); + + Assert.NotNull(compressed); + Assert.NotEmpty(compressed); + Assert.NotEqual(original, compressed); + } + + [Fact] + public async Task StringExtension_DecompressAsync_ShouldDecompressString() + { + string original = "This is a test string for async decompression."; + string compressed = await original.CompressAsync(); + + string decompressed = await compressed.DecompressAsync(); + + Assert.Equal(original, decompressed); + } + + [Fact] + public async Task StringExtension_CompressAsync_WithCancellation_ShouldRespectToken() + { + using CancellationTokenSource cts = new(); + cts.Cancel(); + string original = "Test string"; + + _ = await Assert.ThrowsAnyAsync(async () => + await original.CompressAsync(cancellationToken: cts.Token) + ); + } + + [Fact] + public void StringExtension_Compress_WithNullString_ShouldThrow() + { + string? nullString = null; + + _ = Assert.Throws(() => nullString!.Compress()); + } + + #endregion + + #region Logger Extension Tests + + [Fact] + public void LoggerExtension_LogAndHandle_ShouldReturnTrue() + { + Logger logger = new LoggerConfiguration().CreateLogger(); + InvalidOperationException exception = new("Test exception"); + + bool result = logger.LogAndHandle(exception); + + Assert.True(result); + } + + [Fact] + public void LoggerExtension_LogAndPropagate_ShouldReturnFalse() + { + Logger logger = new LoggerConfiguration().CreateLogger(); + InvalidOperationException exception = new("Test exception"); + + bool result = logger.LogAndPropagate(exception); + + Assert.False(result); + } + + [Fact] + public void LoggerExtension_LogAndHandle_CanBeUsedInCatchWhen() + { + Logger logger = new LoggerConfiguration().CreateLogger(); + bool exceptionCaught; + + try + { + throw new InvalidOperationException("Test"); + } + catch (Exception e) when (logger.LogAndHandle(e)) + { + exceptionCaught = true; + } + + Assert.True(exceptionCaught); + } + + [Fact] + public void LoggerExtension_LogAndPropagate_AllowsExceptionToBubble() + { + Logger logger = new LoggerConfiguration().CreateLogger(); + bool exceptionHandled; + + try + { + throw new InvalidOperationException("Test"); + } + catch (Exception e) when (logger.LogAndPropagate(e)) + { + // This block should not execute because LogAndPropagate returns false + exceptionHandled = true; + } + catch (InvalidOperationException) + { + // Exception should propagate here since LogAndPropagate returns false + exceptionHandled = false; + } + + Assert.False(exceptionHandled); + } + + [Fact] + public void LoggerExtension_LogAndHandle_WithCustomFunction_ShouldLog() + { + Logger logger = new LoggerConfiguration().CreateLogger(); + InvalidOperationException exception = new("Test exception"); + + // Test that it works with explicit function name + bool result = logger.LogAndHandle(exception, "CustomFunction"); + + Assert.True(result); + } + + [Fact] + public void LoggerExtension_LogAndPropagate_WithCustomFunction_ShouldLog() + { + Logger logger = new LoggerConfiguration().CreateLogger(); + InvalidOperationException exception = new("Test exception"); + + // Test that it works with explicit function name + bool result = logger.LogAndPropagate(exception, "CustomFunction"); + + Assert.False(result); + } + + #endregion + + #region Null Handling Tests + + [Fact] + public void StringExtension_Decompress_WithNullString_ShouldThrow() + { + string? nullString = null; + + _ = Assert.Throws(() => nullString!.Decompress()); + } + + [Fact] + public async Task StringExtension_CompressAsync_WithNullString_ShouldThrow() + { + string? nullString = null; + + _ = await Assert.ThrowsAsync(async () => + await nullString!.CompressAsync() + ); + } + + [Fact] + public async Task StringExtension_DecompressAsync_WithNullString_ShouldThrow() + { + string? nullString = null; + + _ = await Assert.ThrowsAsync(async () => + await nullString!.DecompressAsync() + ); + } + + #endregion + + #region Integration Tests + + [Fact] + public void StringExtension_CompressDecompress_RoundTrip_ShouldPreserveData() + { + string[] testStrings = + [ + "Short", + "A medium length string for testing compression", + new string('A', 10000), // Long repetitive string + "Special chars: !@#$%^&*()_+-=[]{}|;':\",./<>?", + "Unicode: 你好世界 🌍🌎🌏", + string.Empty, + ]; + + foreach (string original in testStrings) + { + if (string.IsNullOrEmpty(original)) + { + continue; + } + + string compressed = original.Compress(); + string decompressed = compressed.Decompress(); + + Assert.Equal(original, decompressed); + } + } + + [Fact] + public async Task StringExtension_CompressDecompressAsync_RoundTrip_ShouldPreserveData() + { + string[] testStrings = + [ + "Short async", + "A medium length string for async compression testing", + new string('B', 10000), // Long repetitive string + "Async special chars: !@#$%^&*()_+-=[]{}|;':\",./<>?", + "Async Unicode: 你好世界 🌍🌎🌏", + ]; + + foreach (string original in testStrings) + { + string compressed = await original.CompressAsync(); + string decompressed = await compressed.DecompressAsync(); + + Assert.Equal(original, decompressed); + } + } + + #endregion +} diff --git a/UtilitiesTests/FileExAsyncTests.cs b/UtilitiesTests/FileExAsyncTests.cs new file mode 100644 index 0000000..a6fb663 --- /dev/null +++ b/UtilitiesTests/FileExAsyncTests.cs @@ -0,0 +1,253 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace InsaneGenius.Utilities.Tests; + +public class FileExAsyncTests(UtilitiesTests fixture) : IClassFixture +{ + private readonly UtilitiesTests _fixture = fixture; + + [Fact] + public async Task DeleteFileAsync_WithExistingFile_ShouldReturnTrue() + { + string tempFile = Path.GetTempFileName(); + + try + { + bool result = await FileEx.DeleteFileAsync(tempFile); + + Assert.True(result); + Assert.False(File.Exists(tempFile)); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Fact] + public async Task DeleteDirectoryAsync_WithExistingDirectory_ShouldReturnTrue() + { + string tempDir = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}"); + _ = Directory.CreateDirectory(tempDir); + + try + { + bool result = await FileEx.DeleteDirectoryAsync(tempDir); + + Assert.True(result); + Assert.False(Directory.Exists(tempDir)); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir); + } + } + } + + [Fact] + public async Task DeleteDirectoryAsync_Recursive_ShouldDeleteAllContents() + { + string tempDir = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}"); + _ = Directory.CreateDirectory(tempDir); + string subDir = Path.Combine(tempDir, "subdir"); + _ = Directory.CreateDirectory(subDir); + string testFile = Path.Combine(subDir, "test.txt"); + await File.WriteAllTextAsync(testFile, "test content"); + + try + { + bool result = await FileEx.DeleteDirectoryAsync(tempDir, recursive: true); + + Assert.True(result); + Assert.False(Directory.Exists(tempDir)); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, recursive: true); + } + } + } + + [Fact] + public async Task RenameFileAsync_WithValidPaths_ShouldRenameFile() + { + string tempFile = Path.GetTempFileName(); + string newPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.txt"); + + try + { + bool result = await FileEx.RenameFileAsync(tempFile, newPath); + + Assert.True(result); + Assert.False(File.Exists(tempFile)); + Assert.True(File.Exists(newPath)); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + if (File.Exists(newPath)) + { + File.Delete(newPath); + } + } + } + + [Fact] + public async Task RenameFolderAsync_WithValidPaths_ShouldRenameFolder() + { + string tempDir = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}"); + _ = Directory.CreateDirectory(tempDir); + string newPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}"); + + try + { + bool result = await FileEx.RenameFolderAsync(tempDir, newPath); + + Assert.True(result); + Assert.False(Directory.Exists(tempDir)); + Assert.True(Directory.Exists(newPath)); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir); + } + if (Directory.Exists(newPath)) + { + Directory.Delete(newPath); + } + } + } + + [Fact] + public async Task WaitFileReadableAsync_WithReadableFile_ShouldReturnTrue() + { + string tempFile = Path.GetTempFileName(); + + try + { + await File.WriteAllTextAsync(tempFile, "test content"); + + bool result = await FileEx.WaitFileReadableAsync(tempFile); + + Assert.True(result); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Fact] + public async Task CreateRandomFilledFileAsync_ShouldCreateFile() + { + string tempFile = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.dat"); + long fileSize = 1024 * 1024; // 1MB + + try + { + bool result = await FileEx.CreateRandomFilledFileAsync(tempFile, fileSize); + + Assert.True(result); + Assert.True(File.Exists(tempFile)); + Assert.Equal(fileSize, new FileInfo(tempFile).Length); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Fact] + public async Task CreateSparseFileAsync_ShouldCreateFile() + { + string tempFile = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.dat"); + long fileSize = 1024 * 1024; // 1MB + + try + { + bool result = await FileEx.CreateSparseFileAsync(tempFile, fileSize); + + Assert.True(result); + Assert.True(File.Exists(tempFile)); + Assert.Equal(fileSize, new FileInfo(tempFile).Length); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Fact] + public async Task FileOperationsAsync_WithCancellation_ShouldRespectCancellation() + { + string tempFile = Path.GetTempFileName(); + using CancellationTokenSource cts = new(); + cts.Cancel(); // Cancel immediately + + try + { + // Operations should respect cancellation + bool result = await FileEx.DeleteFileAsync(tempFile, cts.Token); + + // Should either succeed quickly or respect cancellation + Assert.True(result || cts.Token.IsCancellationRequested); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Fact] + public async Task DeleteInsideDirectoryAsync_ShouldDeleteContentsOnly() + { + string tempDir = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}"); + _ = Directory.CreateDirectory(tempDir); + string testFile = Path.Combine(tempDir, "test.txt"); + await File.WriteAllTextAsync(testFile, "test content"); + + try + { + bool result = await FileEx.DeleteInsideDirectoryAsync(tempDir); + + Assert.True(result); + Assert.True(Directory.Exists(tempDir)); // Directory should still exist + Assert.False(File.Exists(testFile)); // But file should be gone + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, recursive: true); + } + } + } +} diff --git a/UtilitiesTests/StringCompressionAsyncTests.cs b/UtilitiesTests/StringCompressionAsyncTests.cs new file mode 100644 index 0000000..a7a0f0e --- /dev/null +++ b/UtilitiesTests/StringCompressionAsyncTests.cs @@ -0,0 +1,104 @@ +using System; +using System.IO.Compression; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace InsaneGenius.Utilities.Tests; + +public class StringCompressionAsyncTests(UtilitiesTests fixture) : IClassFixture +{ + private readonly UtilitiesTests _fixture = fixture; + + [Fact] + public async Task CompressDecompressAsync() + { + // Create random string + Random random = new(); + int length = random.Next(64 * 1024); + byte[] buffer = new byte[length]; + random.NextBytes(buffer); + string text = Convert.ToBase64String(buffer); + + // Compress the string asynchronously + string compressed = await text.CompressAsync(); + + // Decompress asynchronously + string decompressed = await compressed.DecompressAsync(); + + // Compare to original string + Assert.Equal(text, decompressed); + } + + [Theory] + [InlineData(CompressionLevel.Fastest)] + [InlineData(CompressionLevel.Optimal)] + [InlineData(CompressionLevel.SmallestSize)] + public async Task CompressAsync_WithDifferentLevels_ShouldSucceed(CompressionLevel level) + { + string text = + "This is a test string that will be compressed with different compression levels."; + + string compressed = await text.CompressAsync(level); + string decompressed = await compressed.DecompressAsync(); + + Assert.Equal(text, decompressed); + } + + [Fact] + public async Task CompressAsync_WithCancellation_ShouldComplete() + { + // For in-memory operations, cancellation might complete before being checked + // This test verifies the cancellation token parameter is accepted + using CancellationTokenSource cts = new(); + string text = "Test string"; + + string compressed = await text.CompressAsync(cancellationToken: cts.Token); + + Assert.NotNull(compressed); + Assert.NotEmpty(compressed); + } + + [Fact] + public async Task DecompressAsync_WithInvalidBase64_ShouldThrow() + { + string invalidBase64 = "This is not valid Base64!"; + + _ = await Assert.ThrowsAsync(async () => + await invalidBase64.DecompressAsync() + ); + } + + [Fact] + public async Task CompressAsync_WithNullString_ShouldThrowArgumentNullException() + { + string? nullString = null; + + _ = await Assert.ThrowsAsync(async () => + await StringCompression.CompressAsync(nullString!) + ); + } + + [Fact] + public async Task DecompressAsync_WithNullString_ShouldThrowArgumentNullException() + { + string? nullString = null; + + _ = await Assert.ThrowsAsync(async () => + await StringCompression.DecompressAsync(nullString!) + ); + } + + [Fact] + public async Task CompressAsync_LargeString_ShouldCompress() + { + // Create a large repetitive string (should compress well) + string largeText = new('A', 1024 * 1024); // 1MB of 'A's + + string compressed = await largeText.CompressAsync(); + string decompressed = await compressed.DecompressAsync(); + + Assert.Equal(largeText, decompressed); + Assert.True(compressed.Length < largeText.Length, "Compressed size should be smaller"); + } +} diff --git a/UtilitiesTests/StringHistoryTests.cs b/UtilitiesTests/StringHistoryTests.cs new file mode 100644 index 0000000..cf07d63 --- /dev/null +++ b/UtilitiesTests/StringHistoryTests.cs @@ -0,0 +1,233 @@ +using System; +using Xunit; + +namespace InsaneGenius.Utilities.Tests; + +public class StringHistoryTests(UtilitiesTests fixture) : IClassFixture +{ + private readonly UtilitiesTests _fixture = fixture; + + [Fact] + public void Constructor_Default_ShouldInitialize() + { + StringHistory history = new(); + + Assert.NotNull(history); + Assert.Equal(0, history.MaxFirstLines); + Assert.Equal(0, history.MaxLastLines); + Assert.Empty(history.StringList); + } + + [Fact] + public void Constructor_WithLimits_ShouldSetLimits() + { + StringHistory history = new(maxFirstLines: 5, maxLastLines: 3); + + Assert.Equal(5, history.MaxFirstLines); + Assert.Equal(3, history.MaxLastLines); + Assert.Empty(history.StringList); + } + + [Fact] + public void AppendLine_NoLimits_ShouldAddAllLines() + { + StringHistory history = new(); + + for (int i = 0; i < 10; i++) + { + history.AppendLine($"Line {i}"); + } + + Assert.Equal(10, history.StringList.Count); + } + + [Fact] + public void AppendLine_WithFirstLinesLimit_ShouldRespectLimit() + { + StringHistory history = new(maxFirstLines: 5, maxLastLines: 3); + + for (int i = 0; i < 10; i++) + { + history.AppendLine($"Line {i}"); + } + + // Should have 5 first + 3 last = 8 lines + Assert.Equal(8, history.StringList.Count); + Assert.Equal("Line 0", history.StringList[0]); + Assert.Equal("Line 4", history.StringList[4]); + Assert.Equal("Line 9", history.StringList[^1]); + } + + [Fact] + public void AppendLine_BeyondLimits_ShouldRollLastLines() + { + StringHistory history = new(maxFirstLines: 3, maxLastLines: 2); + + for (int i = 0; i < 10; i++) + { + history.AppendLine($"Line {i}"); + } + + // Should have 3 first + 2 last = 5 lines + Assert.Equal(5, history.StringList.Count); + + // First 3 lines + Assert.Equal("Line 0", history.StringList[0]); + Assert.Equal("Line 1", history.StringList[1]); + Assert.Equal("Line 2", history.StringList[2]); + + // Last 2 lines + Assert.Equal("Line 8", history.StringList[3]); + Assert.Equal("Line 9", history.StringList[4]); + } + + [Fact] + public void AppendLine_WithNull_ShouldThrowArgumentNullException() + { + StringHistory history = new(); + + _ = Assert.Throws(() => history.AppendLine(null!)); + } + + [Fact] + public void ToString_EmptyHistory_ShouldReturnEmptyString() + { + StringHistory history = new(); + + string result = history.ToString(); + + Assert.Equal(string.Empty, result); + } + + [Fact] + public void ToString_WithLines_ShouldReturnFormattedString() + { + StringHistory history = new(); + history.AppendLine("Line 1"); + history.AppendLine("Line 2"); + history.AppendLine("Line 3"); + + string result = history.ToString(); + + Assert.Contains("Line 1", result); + Assert.Contains("Line 2", result); + Assert.Contains("Line 3", result); + Assert.EndsWith(Environment.NewLine, result); + } + + [Fact] + public void Properties_CanBeModified_ShouldUpdateLimits() + { + StringHistory history = new() { MaxFirstLines = 10, MaxLastLines = 5 }; + + Assert.Equal(10, history.MaxFirstLines); + Assert.Equal(5, history.MaxLastLines); + } + + [Fact] + public void AppendLine_MultipleSequences_ShouldMaintainCorrectState() + { + StringHistory history = new(maxFirstLines: 2, maxLastLines: 2); + + // Add first batch + history.AppendLine("A"); + history.AppendLine("B"); + Assert.Equal(2, history.StringList.Count); + + // Add more to trigger last lines + history.AppendLine("C"); + history.AppendLine("D"); + Assert.Equal(4, history.StringList.Count); + + // Add more to trigger rolling + history.AppendLine("E"); + Assert.Equal(4, history.StringList.Count); + + history.AppendLine("F"); + Assert.Equal(4, history.StringList.Count); + + // Should have: A, B, E, F + Assert.Equal("A", history.StringList[0]); + Assert.Equal("B", history.StringList[1]); + Assert.Equal("E", history.StringList[2]); + Assert.Equal("F", history.StringList[3]); + } + + [Fact] + public void AppendLine_OnlyFirstLinesLimit_ShouldWork() + { + StringHistory history = new(maxFirstLines: 3, maxLastLines: 0); + + for (int i = 0; i < 10; i++) + { + history.AppendLine($"Line {i}"); + } + + // Should have 3 first lines only + Assert.Equal(3, history.StringList.Count); + Assert.Equal("Line 0", history.StringList[0]); + Assert.Equal("Line 1", history.StringList[1]); + Assert.Equal("Line 2", history.StringList[2]); + } + + [Fact] + public void AppendLine_OnlyLastLinesLimit_ShouldWork() + { + StringHistory history = new(maxFirstLines: 0, maxLastLines: 3); + + for (int i = 0; i < 10; i++) + { + history.AppendLine($"Line {i}"); + } + + // Should have 3 last lines only + Assert.Equal(3, history.StringList.Count); + Assert.Equal("Line 7", history.StringList[0]); + Assert.Equal("Line 8", history.StringList[1]); + Assert.Equal("Line 9", history.StringList[2]); + } + + [Fact] + public void ToString_WithSingleLine_ShouldFormat() + { + StringHistory history = new(); + history.AppendLine("Single line"); + + string result = history.ToString(); + + Assert.Equal("Single line" + Environment.NewLine, result); + } + + [Fact] + public void AppendLine_EmptyString_ShouldBeAllowed() + { + StringHistory history = new(); + + history.AppendLine(string.Empty); + + _ = Assert.Single(history.StringList); + Assert.Equal(string.Empty, history.StringList[0]); + } + + [Fact] + public void AppendLine_SpecialCharacters_ShouldPreserve() + { + StringHistory history = new(); + string specialLine = "Line with special chars: !@#$%^&*()_+-=[]{}|;':\",./<>?"; + + history.AppendLine(specialLine); + + Assert.Equal(specialLine, history.StringList[0]); + } + + [Fact] + public void AppendLine_UnicodeCharacters_ShouldPreserve() + { + StringHistory history = new(); + string unicodeLine = "Unicode: 你好世界 🌍🌎🌏"; + + history.AppendLine(unicodeLine); + + Assert.Equal(unicodeLine, history.StringList[0]); + } +} diff --git a/UtilitiesTests/UtilitiesTests.csproj b/UtilitiesTests/UtilitiesTests.csproj index 5e096ab..fe9a97c 100644 --- a/UtilitiesTests/UtilitiesTests.csproj +++ b/UtilitiesTests/UtilitiesTests.csproj @@ -1,6 +1,9 @@ - net9.0 + net10.0 + latest + true + enable false Pieter Viljoen Pieter Viljoen diff --git a/version.json b/version.json index 9e549b1..daa24fd 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", - "version": "3.3", + "version": "3.4", "publicReleaseRefSpec": [ "^refs/heads/main$" ],