From 9259414150b5f2c5a3c5ce086fc7cd7d52a80b75 Mon Sep 17 00:00:00 2001 From: Paulo Santos Date: Sun, 17 May 2026 11:59:47 -0300 Subject: [PATCH 1/5] fix(config): drop stale INI doc blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ● Preserve documentation blocks only when their associated INI keys are saved. ● Add regression coverage for documented default-valued keys. --- .../ConfigurationDocumentationTests.cs | 86 +++++++++++++++++++ Toolkit.Modern/Configuration/Configuration.cs | 65 ++++++++++++-- 2 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 Toolkit.Modern.Tests/Unit/Configuration/ConfigurationDocumentationTests.cs diff --git a/Toolkit.Modern.Tests/Unit/Configuration/ConfigurationDocumentationTests.cs b/Toolkit.Modern.Tests/Unit/Configuration/ConfigurationDocumentationTests.cs new file mode 100644 index 0000000..e6bfe8f --- /dev/null +++ b/Toolkit.Modern.Tests/Unit/Configuration/ConfigurationDocumentationTests.cs @@ -0,0 +1,86 @@ +using AwesomeAssertions; +using ByteForge.Toolkit.Configuration; +using ByteForge.Toolkit.Tests.Helpers; +using System.ComponentModel; + +namespace ByteForge.Toolkit.Tests.Unit.Configuration +{ + /// + /// Tests Toolkit INI documentation preservation behavior. + /// + [TestClass] + public class ConfigurationDocumentationTests + { + /// + /// Cleans up temporary files after each test. + /// + [TestCleanup] + public void TestCleanup() + { + TestConfigurationHelper.CleanupTempFiles(); + } + + /// + /// Verifies that documentation attached to default-valued keys is removed with those keys during save. + /// + [TestMethod] + public void Save_WithDocumentedDefaultValues_ShouldNotLeaveDetachedDocBlocks() + { + var path = TestConfigurationHelper.CreateTempConfigFile(@"[Docs] +;;; +; Doc 1 +Property1=DefaultValue +;;; +; Doc 2 +Property2=DefaultValue +;;; +; Doc 3 +Property3=DefaultValue +OtherProperty=Non-default-value"); + IConfigurationManager config = new ByteForge.Toolkit.Configuration.Configuration(); + + config.Initialize(path); + config.GetSection("Docs"); + config.Save(); + + var saved = File.ReadAllText(path); + saved.Should().NotContain("Doc 1"); + saved.Should().NotContain("Doc 2"); + saved.Should().NotContain("Doc 3"); + saved.Should().NotContain("Property1="); + saved.Should().NotContain("Property2="); + saved.Should().NotContain("Property3="); + saved.Should().Contain("OtherProperty=Non-default-value"); + } + + /// + /// Test configuration section with default-valued documented properties. + /// + public class DefaultDocumentedConfig + { + /// + /// Gets or sets the first default-valued property. + /// + [DefaultValue("DefaultValue")] + public string? Property1 { get; set; } + + /// + /// Gets or sets the second default-valued property. + /// + [DefaultValue("DefaultValue")] + public string? Property2 { get; set; } + + /// + /// Gets or sets the third default-valued property. + /// + [DefaultValue("DefaultValue")] + public string? Property3 { get; set; } + + /// + /// Gets or sets the non-default property that must not inherit detached documentation. + /// + [DefaultValue("DefaultValue")] + public string? OtherProperty { get; set; } + } + } +} diff --git a/Toolkit.Modern/Configuration/Configuration.cs b/Toolkit.Modern/Configuration/Configuration.cs index fd089ff..d3db775 100644 --- a/Toolkit.Modern/Configuration/Configuration.cs +++ b/Toolkit.Modern/Configuration/Configuration.cs @@ -768,7 +768,7 @@ void ThreadUnsafeSave() var existingKeys = new HashSet(StringComparer.InvariantCultureIgnoreCase); var existingSections = new HashSet(StringComparer.InvariantCultureIgnoreCase); var sectionEndPositions = new Dictionary(StringComparer.InvariantCultureIgnoreCase); - var currentPosition = 0; + List? pendingDocumentationBlock = null; // Get the full path to the INI file var iniFilePath = Path.Combine(_configDirectory, _configFile); @@ -786,7 +786,24 @@ void ThreadUnsafeSave() foreach (var line in iniLines) { var trimmedLine = line.Trim(); - currentPosition = iniData.Count; + + if (IsDocumentationBlockStart(trimmedLine)) + { + pendingDocumentationBlock = new List { line }; + continue; + } + + if (pendingDocumentationBlock != null && IsDocumentationBlockContinuation(trimmedLine)) + { + pendingDocumentationBlock.Add(line); + continue; + } + + if (pendingDocumentationBlock != null && string.IsNullOrWhiteSpace(trimmedLine)) + { + pendingDocumentationBlock.Add(line); + continue; + } // Preserve comments and empty lines as-is if (string.IsNullOrEmpty(trimmedLine) || trimmedLine.StartsWith(";") || trimmedLine.StartsWith("#")) @@ -800,21 +817,26 @@ void ThreadUnsafeSave() { // Extract section name without brackets section = trimmedLine.Trim('[', ']'); + AddPendingDocumentationBlock(iniData, ref pendingDocumentationBlock); iniData.Add(line); existingSections.Add(section); // Track where this section ends for later insertion of new keys - sectionEndPositions[section] = currentPosition + 1; + sectionEndPositions[section] = iniData.Count; continue; } // Skip array and dictionary section values - these are handled specially if (_arraySectionNames.Contains(section) || _dictionarySectionNames.Contains(section)) + { + pendingDocumentationBlock = null; continue; + } // Process key-value pairs var equalsIndex = trimmedLine.IndexOf('='); if (equalsIndex == -1) { + pendingDocumentationBlock = null; iniData.Add(line); continue; } @@ -834,17 +856,19 @@ void ThreadUnsafeSave() } else { - // Skip keys that were set to null (likely default values) + // Skip keys and their attached documentation when they were set to null (likely default values) + pendingDocumentationBlock = null; continue; } // Track that we've processed this key existingKeys.Add(configKey); + AddPendingDocumentationBlock(iniData, ref pendingDocumentationBlock); iniData.Add($"{key}={value}"); // Update the end position of the current section if (!string.IsNullOrEmpty(section)) - sectionEndPositions[section] = currentPosition; + sectionEndPositions[section] = iniData.Count; } // Update the last section's end position @@ -909,4 +933,35 @@ void ThreadUnsafeSave() // Write the updated INI data back to the file File.WriteAllLines(iniFilePath, iniData); } + + /// + /// Adds a pending physical documentation block to an INI output list and clears the pending reference. + /// + /// The INI output lines being built. + /// The pending physical documentation block to add. + private static void AddPendingDocumentationBlock(ICollection iniData, ref List? pendingDocumentationBlock) + { + if (pendingDocumentationBlock == null) + return; + + foreach (var line in pendingDocumentationBlock) + iniData.Add(line); + + pendingDocumentationBlock = null; + } + + /// + /// Determines whether a trimmed INI line starts a Toolkit documentation block. + /// + /// The trimmed INI line to inspect. + /// when the line starts a Toolkit documentation block; otherwise, . + private static bool IsDocumentationBlockStart(string trimmedLine) => trimmedLine == ";;;"; + + /// + /// Determines whether a trimmed INI line continues a Toolkit documentation block. + /// + /// The trimmed INI line to inspect. + /// when the line continues a Toolkit documentation block; otherwise, . + private static bool IsDocumentationBlockContinuation(string trimmedLine) + => trimmedLine.StartsWith(";") && !IsDocumentationBlockStart(trimmedLine); } From 447721ef1aa3ce8aeb835d55a5da0c79b61a42dd Mon Sep 17 00:00:00 2001 From: Paulo Santos Date: Sun, 17 May 2026 12:00:33 -0300 Subject: [PATCH 2/5] feat(toolkit): restore legacy parity APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ● Add legacy-compatible assembly, configuration, database, logging, and timestamp helpers. ● Document migration differences and keep DBAccess2 intentionally excluded. --- MIGRATION-legacy-toolkit.md | 55 ++++ .../Attributes/AssemblyDeveloperAttribute.cs | 84 ++++++ .../ConfigOptionsProviderAttribute.cs | 40 +++ .../Helpers/IniDocumentationReader.cs | 180 +++++++++++++ .../Interfaces/IConfigOptionsProvider.cs | 18 ++ .../Models/ConfigDocumentationCatalog.cs | 252 ++++++++++++++++++ .../Models/ConfigSectionSchema.cs | 169 ++++++++++++ .../Providers/CultureInfoOptionsProvider.cs | 40 +++ .../Providers/EncodingOptionsProvider.cs | 37 +++ .../Data/Database/DBAccess.Compatibility.cs | 148 ++++++++++ Toolkit.Modern/Data/Database/DBAccess.Core.cs | 2 +- .../Data/Database/DatabaseAccessFactory.cs | 90 +++++++ .../Data/Database/IDatabaseAccess.cs | 143 ++++++++++ Toolkit.Modern/Logging/LogContext.cs | 107 ++++++++ Toolkit.Modern/Logging/LogSecretMasker.cs | 40 +++ .../Logging/Models/AsyncLoggerOptions.cs | 38 +++ .../Logging/Models/LogRoutingContext.cs | 67 +++++ .../Utilities/ReportTimestampFormatter.cs | 223 ++++++++++++++++ 18 files changed, 1732 insertions(+), 1 deletion(-) create mode 100644 MIGRATION-legacy-toolkit.md create mode 100644 Toolkit.Modern/Attributes/AssemblyDeveloperAttribute.cs create mode 100644 Toolkit.Modern/Configuration/Attributes/ConfigOptionsProviderAttribute.cs create mode 100644 Toolkit.Modern/Configuration/Helpers/IniDocumentationReader.cs create mode 100644 Toolkit.Modern/Configuration/Interfaces/IConfigOptionsProvider.cs create mode 100644 Toolkit.Modern/Configuration/Models/ConfigDocumentationCatalog.cs create mode 100644 Toolkit.Modern/Configuration/Models/ConfigSectionSchema.cs create mode 100644 Toolkit.Modern/Configuration/Providers/CultureInfoOptionsProvider.cs create mode 100644 Toolkit.Modern/Configuration/Providers/EncodingOptionsProvider.cs create mode 100644 Toolkit.Modern/Data/Database/DBAccess.Compatibility.cs create mode 100644 Toolkit.Modern/Data/Database/DatabaseAccessFactory.cs create mode 100644 Toolkit.Modern/Data/Database/IDatabaseAccess.cs create mode 100644 Toolkit.Modern/Logging/LogContext.cs create mode 100644 Toolkit.Modern/Logging/LogSecretMasker.cs create mode 100644 Toolkit.Modern/Logging/Models/AsyncLoggerOptions.cs create mode 100644 Toolkit.Modern/Logging/Models/LogRoutingContext.cs create mode 100644 Toolkit.Modern/Utilities/ReportTimestampFormatter.cs diff --git a/MIGRATION-legacy-toolkit.md b/MIGRATION-legacy-toolkit.md new file mode 100644 index 0000000..cb2be51 --- /dev/null +++ b/MIGRATION-legacy-toolkit.md @@ -0,0 +1,55 @@ +# Migrating From The Legacy .NET Framework Toolkit + +This repository is the modernized ByteForge Toolkit line. It is being brought +back to feature parity with the legacy `.NET Framework 4.8` toolkit at: + +`C:\Users\pauls\source\TelecomInc\MainProjects\ByteForge.Toolkit` + +The goal is drop-in compatibility where practical, while preserving the modern +project's existing folder and namespace style. + +## Layout And Namespace Differences + +Some legacy folders were renamed during the modernization. Keep using the modern +namespaces in new code. + +| Legacy location | Modern location | Modern namespace | +| --- | --- | --- | +| `CLI` | `Toolkit.Modern/CommandLine` | `ByteForge.Toolkit.CommandLine` | +| `Utils` | `Toolkit.Modern/Utilities` | `ByteForge.Toolkit.Utilities` | +| `Converters` | `Toolkit.Modern/Utilities` or `Toolkit.Modern/Data/Database` | Existing modern namespace for the type | +| `Attributes` | `Toolkit.Modern/Data/Attributes` for data mapping attributes | `ByteForge.Toolkit.Data` | +| `Configuration` | `Toolkit.Modern/Configuration` | `ByteForge.Toolkit.Configuration` | + +Legacy assembly metadata attributes are an exception: they remain in +`ByteForge.Toolkit` so assembly info files can continue to use the short +attribute names with `using ByteForge.Toolkit`. + +## DBAccess2 + +`DBAccess2` was started in the legacy toolkit but was abandoned before becoming +functional. It is intentionally not ported to the modern toolkit. + +Compatibility notes: + +- `DBAccess` remains the supported database access implementation. +- `DatabaseAccessFactory` exists for callers that used the legacy factory shape. +- The `useDBAccess2` factory flag is accepted for source compatibility but is + ignored. +- `CreateModern(DatabaseOptions)` remains only as an obsolete compatibility shim + and returns `DBAccess`. +- Do not migrate callers to `DBAccess2`; migrate them to `DBAccess` or + `DatabaseAccessFactory.Create(...)`. + +## Current Compatibility Additions + +The modern toolkit includes these legacy-parity additions: + +- Configuration documentation support types such as + `ConfigDocumentationCatalog`, `ConfigSectionSchema`, and + `ConfigOptionsProviderAttribute`. +- Logging context helpers such as `LogContext`, `LogSecretMasker`, and + `LogRoutingContext`. +- `IDatabaseAccess` plus cancellation-token overloads on `DBAccess`. +- `ReportTimestampFormatter` under `ByteForge.Toolkit.Utilities`. +- Assembly metadata attributes under `ByteForge.Toolkit`. diff --git a/Toolkit.Modern/Attributes/AssemblyDeveloperAttribute.cs b/Toolkit.Modern/Attributes/AssemblyDeveloperAttribute.cs new file mode 100644 index 0000000..0c6b4f0 --- /dev/null +++ b/Toolkit.Modern/Attributes/AssemblyDeveloperAttribute.cs @@ -0,0 +1,84 @@ +using System; + +namespace ByteForge.Toolkit +{ + /// + /// Represents an attribute that specifies the developer company for an assembly. + /// + [AttributeUsage(AttributeTargets.Assembly)] + public sealed class AssemblyDeveloperCompanyAttribute : Attribute + { + /// + /// Gets the name of the company. + /// + public string Name { get; } + /// + /// Initializes a new instance of the class with the specified company name. + /// + /// The name of the company. + public AssemblyDeveloperCompanyAttribute(string companyName) + { + Name = companyName; + } + } + + /// + /// Represents an attribute that specifies the developer for an assembly. + /// + [AttributeUsage(AttributeTargets.Assembly)] + public sealed class AssemblyDeveloperAttribute : Attribute + { + /// + /// Gets the name of the developer. + /// + public string Name { get; } + /// + /// Initializes a new instance of the class with the specified developer name. + /// + /// The name of the developer. + public AssemblyDeveloperAttribute(string developerName) + { + Name = developerName; + } + } + + /// + /// Represents an attribute that specifies the company URL for an assembly. + /// + [AttributeUsage(AttributeTargets.Assembly)] + public sealed class AssemblyCompanyUrlAttribute : Attribute + { + /// + /// Gets the URL of the company. + /// + public string Url { get; } + /// + /// Initializes a new instance of the class with the specified company URL. + /// + /// The URL of the company. + public AssemblyCompanyUrlAttribute(string companyUrl) + { + Url = companyUrl; + } + } + + /// + /// Represents an attribute that specifies the developer company URL for an assembly. + /// + [AttributeUsage(AttributeTargets.Assembly)] + public sealed class AssemblyDeveloperCompanyUrlAttribute : Attribute + { + /// + /// Gets the URL of the developer company. + /// + public string Url { get; } + /// + /// Initializes a new instance of the class with the specified developer company URL. + /// + /// The URL of the developer company. + public AssemblyDeveloperCompanyUrlAttribute(string developerUrl) + { + Url = developerUrl; + } + } +} diff --git a/Toolkit.Modern/Configuration/Attributes/ConfigOptionsProviderAttribute.cs b/Toolkit.Modern/Configuration/Attributes/ConfigOptionsProviderAttribute.cs new file mode 100644 index 0000000..bf7df40 --- /dev/null +++ b/Toolkit.Modern/Configuration/Attributes/ConfigOptionsProviderAttribute.cs @@ -0,0 +1,40 @@ +using System; + +namespace ByteForge.Toolkit.Configuration +{ + /// + /// Declares a provider that supplies selectable configuration values for a reflected property. + /// + [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] + public sealed class ConfigOptionsProviderAttribute : Attribute + { + /// + /// Initializes a new instance of the class. + /// + /// The concrete option provider type. + public ConfigOptionsProviderAttribute(Type providerType) + { + if (providerType == null) + throw new ArgumentNullException(nameof(providerType)); + + if (!typeof(IConfigOptionsProvider).IsAssignableFrom(providerType)) + throw new ArgumentException("The provider type must implement IConfigOptionsProvider.", nameof(providerType)); + + ProviderType = providerType; + } + + /// + /// Gets the concrete option provider type. + /// + public Type ProviderType { get; } + + /// + /// Creates a provider instance for the attributed property. + /// + /// The configured option provider instance. + public IConfigOptionsProvider CreateProvider() + { + return (IConfigOptionsProvider)Activator.CreateInstance(ProviderType); + } + } +} diff --git a/Toolkit.Modern/Configuration/Helpers/IniDocumentationReader.cs b/Toolkit.Modern/Configuration/Helpers/IniDocumentationReader.cs new file mode 100644 index 0000000..f839f55 --- /dev/null +++ b/Toolkit.Modern/Configuration/Helpers/IniDocumentationReader.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace ByteForge.Toolkit.Configuration +{ + /// + /// Reads Toolkit INI documentation blocks and validates names before the Microsoft INI provider loads values. + /// + internal static class IniDocumentationReader + { + /// + /// Reads documentation and validation metadata from an INI file. + /// + /// The INI file path. + /// The parsed documentation catalog. + public static ConfigDocumentationCatalog Read(string path) + { + if (string.IsNullOrEmpty(path)) + throw new ArgumentNullException(nameof(path)); + + var catalog = new ConfigDocumentationCatalog(); + var sections = new Dictionary(StringComparer.OrdinalIgnoreCase); + var keysBySection = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var currentSection = string.Empty; + var pendingDocLines = new List(); + var collectingDoc = false; + var lines = File.ReadAllLines(path); + + for (var index = 0; index < lines.Length; index++) + { + var lineNumber = index + 1; + var line = lines[index]; + var trimmedLine = line.Trim(); + + if (trimmedLine == ";;;") + { + pendingDocLines.Clear(); + collectingDoc = true; + continue; + } + + if (collectingDoc) + { + if (trimmedLine.StartsWith(";") && trimmedLine != ";;;") + { + pendingDocLines.Add(UnwrapDocumentationLine(trimmedLine)); + continue; + } + + if (string.IsNullOrWhiteSpace(trimmedLine)) + continue; + + collectingDoc = false; + } + + if (IsIniCommentOrBlank(trimmedLine)) + continue; + + if (trimmedLine.StartsWith("[") && trimmedLine.EndsWith("]")) + { + currentSection = trimmedLine.Substring(1, trimmedLine.Length - 2).Trim(); + ValidateReservedName(currentSection, "section", path, lineNumber); + + if (sections.TryGetValue(currentSection, out var firstLine)) + throw new FormatException($"The section '{currentSection}' was found again in file '{path}' at line {lineNumber}. Its first appearance was at line {firstLine}."); + + sections.Add(currentSection, lineNumber); + keysBySection[currentSection] = new Dictionary(StringComparer.OrdinalIgnoreCase); + AttachSectionDocumentation(catalog, currentSection, pendingDocLines, path, lineNumber); + pendingDocLines.Clear(); + continue; + } + + var equalsIndex = trimmedLine.IndexOf('='); + if (equalsIndex < 0) + { + pendingDocLines.Clear(); + continue; + } + + var key = trimmedLine.Substring(0, equalsIndex).Trim(); + ValidateReservedName(key, "key", path, lineNumber); + + if (!keysBySection.TryGetValue(currentSection, out var sectionKeys)) + { + sectionKeys = new Dictionary(StringComparer.OrdinalIgnoreCase); + keysBySection[currentSection] = sectionKeys; + } + + if (sectionKeys.TryGetValue(key, out var firstKeyLine)) + { + var displaySection = string.IsNullOrEmpty(currentSection) ? "" : currentSection; + throw new FormatException($"The key '{key}' in section '{displaySection}' was found again in file '{path}' at line {lineNumber}. Its first appearance was at line {firstKeyLine}."); + } + + sectionKeys.Add(key, lineNumber); + AttachItemDocumentation(catalog, currentSection, key, pendingDocLines, path, lineNumber); + pendingDocLines.Clear(); + } + + return catalog; + } + + /// + /// Validates that an INI name does not contain reserved hierarchy separators. + /// + /// The section or key name. + /// The display name for the kind of name being validated. + /// The INI file path. + /// The one-based line number where the name was found. + private static void ValidateReservedName(string name, string nameKind, string path, int lineNumber) + { + if (name?.IndexOf(':') >= 0) + throw new FormatException($"The {nameKind} name '{name}' in file '{path}' at line {lineNumber} contains ':', which is reserved for Toolkit configuration paths."); + } + + /// + /// Adds pending documentation to a section. + /// + /// The documentation catalog to update. + /// The section name. + /// The pending documentation lines. + /// The INI file path. + /// The section line number. + private static void AttachSectionDocumentation(ConfigDocumentationCatalog catalog, string section, IReadOnlyCollection pendingDocLines, string path, int lineNumber) + { + var description = JoinDocumentation(pendingDocLines); + if (!string.IsNullOrWhiteSpace(description)) + catalog.SetPhysicalSection(section, description, path, lineNumber); + } + + /// + /// Adds pending documentation to a configuration item. + /// + /// The documentation catalog to update. + /// The section name. + /// The item key. + /// The pending documentation lines. + /// The INI file path. + /// The item line number. + private static void AttachItemDocumentation(ConfigDocumentationCatalog catalog, string section, string key, IReadOnlyCollection pendingDocLines, string path, int lineNumber) + { + var description = JoinDocumentation(pendingDocLines); + if (!string.IsNullOrWhiteSpace(description)) + catalog.SetPhysicalItem(section, key, description, path, lineNumber); + } + + /// + /// Converts collected documentation lines into a description string. + /// + /// The pending documentation lines. + /// The normalized description text. + private static string JoinDocumentation(IReadOnlyCollection pendingDocLines) + => pendingDocLines == null || pendingDocLines.Count == 0 + ? null + : string.Join(Environment.NewLine, pendingDocLines).Trim(); + + /// + /// Determines whether a trimmed INI line is blank or starts with a supported comment marker. + /// + /// The trimmed INI line to inspect. + /// when the line is blank or starts with ; or #; otherwise, . + private static bool IsIniCommentOrBlank(string trimmedLine) + => string.IsNullOrWhiteSpace(trimmedLine) + || trimmedLine.StartsWith(";") + || trimmedLine.StartsWith("#"); + + /// + /// Removes the comment leader from a documentation line. + /// + /// The trimmed documentation comment line. + /// The documentation text without the comment leader. + private static string UnwrapDocumentationLine(string line) + { + var value = line.Substring(1); + return value.StartsWith(" ") ? value.Substring(1) : value; + } + } +} diff --git a/Toolkit.Modern/Configuration/Interfaces/IConfigOptionsProvider.cs b/Toolkit.Modern/Configuration/Interfaces/IConfigOptionsProvider.cs new file mode 100644 index 0000000..e4d2769 --- /dev/null +++ b/Toolkit.Modern/Configuration/Interfaces/IConfigOptionsProvider.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using System.Reflection; + +namespace ByteForge.Toolkit.Configuration +{ + /// + /// Supplies selectable editor values for a strongly typed configuration property. + /// + public interface IConfigOptionsProvider + { + /// + /// Gets the selectable values for a reflected configuration property. + /// + /// The reflected configuration property requesting options. + /// The selectable configuration options. + IReadOnlyCollection GetOptions(PropertyInfo property); + } +} diff --git a/Toolkit.Modern/Configuration/Models/ConfigDocumentationCatalog.cs b/Toolkit.Modern/Configuration/Models/ConfigDocumentationCatalog.cs new file mode 100644 index 0000000..5c04451 --- /dev/null +++ b/Toolkit.Modern/Configuration/Models/ConfigDocumentationCatalog.cs @@ -0,0 +1,252 @@ +using System; +using System.Collections.Generic; + +namespace ByteForge.Toolkit.Configuration +{ + /// + /// Stores physical and attribute-based documentation for INI configuration sections and keys. + /// + public sealed class ConfigDocumentationCatalog + { + /// + /// Stores section documentation entries by section name. + /// + private readonly Dictionary _sections = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + /// + /// Stores key documentation entries by fully qualified configuration key. + /// + private readonly Dictionary _items = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + /// + /// Gets an empty documentation catalog. + /// + public static ConfigDocumentationCatalog Empty => new ConfigDocumentationCatalog(); + + /// + /// Gets the documented sections keyed by section name. + /// + public IReadOnlyDictionary Sections => _sections; + + /// + /// Gets the documented items keyed by fully qualified configuration key. + /// + public IReadOnlyDictionary Items => _items; + + /// + /// Adds or replaces physical documentation for a section. + /// + /// The section name. + /// The section description. + /// The source INI file path. + /// The line number where the section was declared. + public void SetPhysicalSection(string section, string description, string sourcePath, int lineNumber) + { + if (string.IsNullOrWhiteSpace(description)) + return; + + _sections[section] = new ConfigSectionDocumentation(section, description, sourcePath, lineNumber, true); + } + + /// + /// Adds or replaces physical documentation for a configuration item. + /// + /// The section name. + /// The item key. + /// The item description. + /// The source INI file path. + /// The line number where the item was declared. + public void SetPhysicalItem(string section, string key, string description, string sourcePath, int lineNumber) + { + if (string.IsNullOrWhiteSpace(description)) + return; + + _items[BuildItemKey(section, key)] = new ConfigItemDocumentation(section, key, description, sourcePath, lineNumber, true); + } + + /// + /// Adds fallback documentation for a section when physical documentation is missing. + /// + /// The section name. + /// The section description. + public void SetFallbackSection(string section, string description) + { + if (string.IsNullOrWhiteSpace(description) || _sections.ContainsKey(section)) + return; + + _sections[section] = new ConfigSectionDocumentation(section, description, null, 0, false); + } + + /// + /// Adds fallback documentation for an item when physical documentation is missing. + /// + /// The section name. + /// The item key. + /// The item description. + public void SetFallbackItem(string section, string key, string description) + { + var itemKey = BuildItemKey(section, key); + if (string.IsNullOrWhiteSpace(description) || _items.ContainsKey(itemKey)) + return; + + _items[itemKey] = new ConfigItemDocumentation(section, key, description, null, 0, false); + } + + /// + /// Merges fallback documentation from another catalog without replacing physical documentation. + /// + /// The catalog containing fallback documentation. + public void MergeFallbacks(ConfigDocumentationCatalog fallbackCatalog) + { + if (fallbackCatalog == null) + return; + + foreach (var section in fallbackCatalog.Sections.Values) + SetFallbackSection(section.Name, section.Description); + + foreach (var item in fallbackCatalog.Items.Values) + SetFallbackItem(item.Section, item.Key, item.Description); + } + + /// + /// Gets documentation for a section. + /// + /// The section name. + /// The section documentation, or when no documentation exists. + public ConfigSectionDocumentation GetSection(string section) + { + if (string.IsNullOrEmpty(section)) + return null; + + return _sections.TryGetValue(section, out var value) ? value : null; + } + + /// + /// Gets documentation for an item. + /// + /// The section name. + /// The item key. + /// The item documentation, or when no documentation exists. + public ConfigItemDocumentation GetItem(string section, string key) + { + if (string.IsNullOrEmpty(section) || string.IsNullOrEmpty(key)) + return null; + + var itemKey = BuildItemKey(section, key); + return _items.TryGetValue(itemKey, out var value) ? value : null; + } + + /// + /// Builds the canonical documentation key for a section item. + /// + /// The section name. + /// The item key. + /// The canonical item key. + public static string BuildItemKey(string section, string key) => $"{section}:{key}"; + } + + /// + /// Describes an INI configuration section. + /// + public sealed class ConfigSectionDocumentation + { + /// + /// Initializes a new instance of the class. + /// + /// The section name. + /// The section description. + /// The source INI file path, when the description came from the file. + /// The source line number, when the description came from the file. + /// Whether the description came from the physical INI file. + public ConfigSectionDocumentation(string name, string description, string sourcePath, int lineNumber, bool isPhysical) + { + Name = name; + Description = description; + SourcePath = sourcePath; + LineNumber = lineNumber; + IsPhysical = isPhysical; + } + + /// + /// Gets the section name. + /// + public string Name { get; } + + /// + /// Gets the section description. + /// + public string Description { get; } + + /// + /// Gets the source INI file path, when available. + /// + public string SourcePath { get; } + + /// + /// Gets the source line number, when available. + /// + public int LineNumber { get; } + + /// + /// Gets a value indicating whether the description came from the physical INI file. + /// + public bool IsPhysical { get; } + } + + /// + /// Describes an INI configuration key. + /// + public sealed class ConfigItemDocumentation + { + /// + /// Initializes a new instance of the class. + /// + /// The section name. + /// The item key. + /// The item description. + /// The source INI file path, when the description came from the file. + /// The source line number, when the description came from the file. + /// Whether the description came from the physical INI file. + public ConfigItemDocumentation(string section, string key, string description, string sourcePath, int lineNumber, bool isPhysical) + { + Section = section; + Key = key; + Description = description; + SourcePath = sourcePath; + LineNumber = lineNumber; + IsPhysical = isPhysical; + } + + /// + /// Gets the section name. + /// + public string Section { get; } + + /// + /// Gets the item key. + /// + public string Key { get; } + + /// + /// Gets the item description. + /// + public string Description { get; } + + /// + /// Gets the source INI file path, when available. + /// + public string SourcePath { get; } + + /// + /// Gets the source line number, when available. + /// + public int LineNumber { get; } + + /// + /// Gets a value indicating whether the description came from the physical INI file. + /// + public bool IsPhysical { get; } + } +} diff --git a/Toolkit.Modern/Configuration/Models/ConfigSectionSchema.cs b/Toolkit.Modern/Configuration/Models/ConfigSectionSchema.cs new file mode 100644 index 0000000..8d61b5d --- /dev/null +++ b/Toolkit.Modern/Configuration/Models/ConfigSectionSchema.cs @@ -0,0 +1,169 @@ +using System.Collections.Generic; + +namespace ByteForge.Toolkit.Configuration +{ + /// + /// Describes the editable keys exposed by a registered strongly typed configuration section. + /// + public sealed class ConfigSectionSchema + { + /// + /// Initializes a new instance of the class. + /// + /// The configuration section name. + /// The editable item schemas declared for the section. + public ConfigSectionSchema(string name, IReadOnlyCollection items) + { + Name = name; + Items = items ?? new List(); + } + + /// + /// Gets the configuration section name. + /// + public string Name { get; } + + /// + /// Gets the editable item schemas declared for the section. + /// + public IReadOnlyCollection Items { get; } + } + + /// + /// Describes an editable strongly typed configuration property. + /// + public sealed class ConfigItemSchema + { + /// + /// Initializes a new instance of the class. + /// + /// The configuration key name. + /// The reflected property name. + /// The stringified default value for the key. + /// Whether the key stores encrypted values. + /// The friendly reflected data type label for the property. + /// Whether the key maps to a boolean property. + /// The valid enum member names when this item maps to an enum property. + /// The selectable editor options when this item has a provider-backed editor. + /// Whether this item stores values in an array backing section. + /// Whether this item stores values in a dictionary backing section. + /// The configured backing section name for array or dictionary values. + public ConfigItemSchema( + string key, + string propertyName, + string defaultValue, + bool isEncrypted, + string displayType, + bool isBoolean = false, + IReadOnlyCollection enumValues = null, + IReadOnlyCollection options = null, + bool isArray = false, + bool isDictionary = false, + string collectionSectionName = null) + { + Key = key; + PropertyName = propertyName; + DefaultValue = defaultValue; + IsEncrypted = isEncrypted; + DisplayType = displayType; + IsBoolean = isBoolean; + EnumValues = enumValues ?? new List(); + Options = options ?? new List(); + IsArray = isArray; + IsDictionary = isDictionary; + CollectionSectionName = collectionSectionName; + } + + /// + /// Gets the configuration key name. + /// + public string Key { get; } + + /// + /// Gets the reflected property name. + /// + public string PropertyName { get; } + + /// + /// Gets the stringified default value for the key. + /// + public string DefaultValue { get; } + + /// + /// Gets a value indicating whether the key stores encrypted values. + /// + public bool IsEncrypted { get; } + + /// + /// Gets the friendly reflected data type label for the property. + /// + public string DisplayType { get; } + + /// + /// Gets a value indicating whether this item maps to a boolean property. + /// + public bool IsBoolean { get; } + + /// + /// Gets a value indicating whether this item stores values in an array backing section. + /// + public bool IsArray { get; } + + /// + /// Gets a value indicating whether this item stores values in a dictionary backing section. + /// + public bool IsDictionary { get; } + + /// + /// Gets the configured backing section name for array or dictionary values. + /// + public string CollectionSectionName { get; } + + /// + /// Gets the valid enum member names when this item maps to an enum property. + /// + public IReadOnlyCollection EnumValues { get; } + + /// + /// Gets the selectable editor options when this item has a provider-backed editor. + /// + public IReadOnlyCollection Options { get; } + + /// + /// Gets a value indicating whether this item maps to an enum property. + /// + public bool IsEnum => EnumValues.Count > 0; + + /// + /// Gets a value indicating whether this item has selectable editor options. + /// + public bool HasOptions => Options.Count > 0; + } + + /// + /// Describes one selectable configuration editor option. + /// + public sealed class ConfigItemOption + { + /// + /// Initializes a new instance of the class. + /// + /// The value persisted to configuration when selected. + /// The label displayed by the editor. + public ConfigItemOption(string value, string label) + { + Value = value ?? string.Empty; + Label = string.IsNullOrWhiteSpace(label) ? Value : label; + } + + /// + /// Gets the value persisted to configuration when selected. + /// + public string Value { get; } + + /// + /// Gets the label displayed by the editor. + /// + public string Label { get; } + } +} diff --git a/Toolkit.Modern/Configuration/Providers/CultureInfoOptionsProvider.cs b/Toolkit.Modern/Configuration/Providers/CultureInfoOptionsProvider.cs new file mode 100644 index 0000000..0d120fd --- /dev/null +++ b/Toolkit.Modern/Configuration/Providers/CultureInfoOptionsProvider.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Reflection; + +namespace ByteForge.Toolkit.Configuration +{ + /// + /// Supplies installed .NET cultures as selectable configuration values. + /// + public sealed class CultureInfoOptionsProvider : IConfigOptionsProvider + { + /// + /// Gets installed culture names for a reflected configuration property. + /// + /// The reflected configuration property requesting options. + /// The selectable culture options. + public IReadOnlyCollection GetOptions(PropertyInfo property) + { + var cultures = CultureInfo.GetCultures(CultureTypes.AllCultures) + .OrderBy(culture => culture.Name, System.StringComparer.OrdinalIgnoreCase) + .Select(culture => new ConfigItemOption(culture.Name, GetCultureLabel(culture))) + .ToList(); + + return cultures; + } + + /// + /// Gets the display label for a culture option. + /// + /// The culture to label. + /// The culture option label. + private static string GetCultureLabel(CultureInfo culture) + { + return string.IsNullOrEmpty(culture.Name) + ? "Invariant Culture" + : $"{culture.Name} - {culture.EnglishName}"; + } + } +} diff --git a/Toolkit.Modern/Configuration/Providers/EncodingOptionsProvider.cs b/Toolkit.Modern/Configuration/Providers/EncodingOptionsProvider.cs new file mode 100644 index 0000000..d54f99e --- /dev/null +++ b/Toolkit.Modern/Configuration/Providers/EncodingOptionsProvider.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text; + +namespace ByteForge.Toolkit.Configuration +{ + /// + /// Supplies installed text encodings as selectable configuration values. + /// + public sealed class EncodingOptionsProvider : IConfigOptionsProvider + { + /// + /// Gets installed text encodings for a reflected configuration property. + /// + /// The reflected configuration property requesting options. + /// The selectable encoding options. + public IReadOnlyCollection GetOptions(PropertyInfo property) + { + return Encoding.GetEncodings() + .Select(info => info.GetEncoding()) + .OrderBy(encoding => encoding.WebName, System.StringComparer.OrdinalIgnoreCase) + .Select(encoding => new ConfigItemOption(encoding.WebName, GetEncodingLabel(encoding))) + .ToList(); + } + + /// + /// Gets the display label for an encoding option. + /// + /// The encoding to label. + /// The encoding option label. + private static string GetEncodingLabel(Encoding encoding) + { + return $"{encoding.WebName} - {encoding.EncodingName}"; + } + } +} diff --git a/Toolkit.Modern/Data/Database/DBAccess.Compatibility.cs b/Toolkit.Modern/Data/Database/DBAccess.Compatibility.cs new file mode 100644 index 0000000..8c5ec85 --- /dev/null +++ b/Toolkit.Modern/Data/Database/DBAccess.Compatibility.cs @@ -0,0 +1,148 @@ +using System.Data; +using System.Threading; +using System.Threading.Tasks; + +namespace ByteForge.Toolkit.Data; + +public partial class DBAccess +{ + /// + /// Tests the database connection asynchronously while accepting a cancellation token. + /// + /// The token that can cancel the operation before it starts. + /// A task whose result indicates whether the database connection succeeded. + public Task TestConnectionAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.Run(TestConnection, cancellationToken); + } + + /// + /// Tries to get a typed value asynchronously while accepting a cancellation token. + /// + /// The value type to return. + /// The query to execute. + /// The token that can cancel the operation before it starts. + /// The query parameter values. + /// A task whose result contains the success flag and value. + public Task<(bool Success, T? Value)> TryGetValueAsync(string query, CancellationToken cancellationToken, params object?[]? arguments) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.Run(() => + { + var success = TryGetValue(out var value, query, arguments); + return (success, value); + }, cancellationToken); + } + + /// + /// Gets a typed value asynchronously while accepting a cancellation token. + /// + /// The value type to return. + /// The query to execute. + /// The token that can cancel the operation before it starts. + /// The query parameter values. + /// A task whose result contains the value. + public Task GetValueAsync(string query, CancellationToken cancellationToken, params object?[]? arguments) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.Run(() => GetValue(query, arguments), cancellationToken); + } + + /// + /// Tries to get an untyped value asynchronously while accepting a cancellation token. + /// + /// The query to execute. + /// The token that can cancel the operation before it starts. + /// The query parameter values. + /// A task whose result contains the success flag and value. + public Task<(bool Success, object? Value)> TryGetValueAsync(string query, CancellationToken cancellationToken, params object?[]? arguments) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.Run(() => + { + var success = TryGetValue(out var value, query, arguments); + return (success, value); + }, cancellationToken); + } + + /// + /// Gets an untyped value asynchronously while accepting a cancellation token. + /// + /// The query to execute. + /// The token that can cancel the operation before it starts. + /// The query parameter values. + /// A task whose result contains the value. + public Task GetValueAsync(string query, CancellationToken cancellationToken, params object?[]? arguments) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.Run(() => GetValue(query, arguments), cancellationToken); + } + + /// + /// Gets the first matching record asynchronously while accepting a cancellation token. + /// + /// The query to execute. + /// The token that can cancel the operation before it starts. + /// The query parameter values. + /// A task whose result contains the first matching row. + public Task GetRecordAsync(string query, CancellationToken cancellationToken, params object?[]? arguments) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.Run(() => GetRecord(query, arguments), cancellationToken); + } + + /// + /// Gets typed records asynchronously while accepting a cancellation token. + /// + /// The record type to materialize. + /// The query to execute. + /// The token that can cancel the operation before it starts. + /// The query parameter values. + /// A task whose result contains the materialized records. + public Task GetRecordsAsync(string query, CancellationToken cancellationToken, params object?[]? arguments) where T : class, new() + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.Run(() => GetRecords(query, arguments), cancellationToken); + } + + /// + /// Gets records asynchronously while accepting a cancellation token. + /// + /// The query to execute. + /// The token that can cancel the operation before it starts. + /// The query parameter values. + /// A task whose result contains the matching rows. + public Task GetRecordsAsync(string query, CancellationToken cancellationToken, params object?[]? arguments) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.Run(() => GetRecords(query, arguments), cancellationToken); + } + + /// + /// Executes a non-query asynchronously while accepting a cancellation token. + /// + /// The query to execute. + /// The token that can cancel the operation before it starts. + /// The query parameter values. + /// A task whose result indicates whether the query succeeded. + public Task ExecuteQueryAsync(string query, CancellationToken cancellationToken, params object?[]? arguments) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.Run(() => ExecuteQuery(query, arguments), cancellationToken); + } + + /// + /// Executes a SQL script asynchronously while accepting a cancellation token. + /// + /// The SQL script to execute. + /// The token that can cancel the operation before it starts. + /// The query parameter values. + /// Whether result sets should be captured. + /// A task whose result contains the script execution result. + public Task ExecuteScriptAsync(string script, CancellationToken cancellationToken, object[]? arguments = null, bool captureResults = false) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.Run(() => ExecuteScript(script, arguments, captureResults), cancellationToken); + } +} diff --git a/Toolkit.Modern/Data/Database/DBAccess.Core.cs b/Toolkit.Modern/Data/Database/DBAccess.Core.cs index aea920c..c4f4f79 100644 --- a/Toolkit.Modern/Data/Database/DBAccess.Core.cs +++ b/Toolkit.Modern/Data/Database/DBAccess.Core.cs @@ -36,7 +36,7 @@ namespace ByteForge.Toolkit.Data; /// Includes utility methods for type conversion and timing the execution of database operations. /// Designed to work with SQL Server and ODBC databases, and can be configured using a configuration file. /// -public partial class DBAccess +public partial class DBAccess : IDatabaseAccess { /// /// Enum representing the type of database. diff --git a/Toolkit.Modern/Data/Database/DatabaseAccessFactory.cs b/Toolkit.Modern/Data/Database/DatabaseAccessFactory.cs new file mode 100644 index 0000000..a246d80 --- /dev/null +++ b/Toolkit.Modern/Data/Database/DatabaseAccessFactory.cs @@ -0,0 +1,90 @@ +using ByteForge.Toolkit.Configuration; +using System; + +namespace ByteForge.Toolkit.Data; + +/// +/// Creates database access instances for the configured database provider. +/// +public static class DatabaseAccessFactory +{ + /// + /// Creates a database access instance using the selected database from configuration. + /// + /// A instance for the selected database section. + public static IDatabaseAccess Create() + { + var rootOptions = TryGetRootOptions(); + var dbSection = rootOptions?.SelectedDatabase ?? string.Empty; + return Create(dbSection); + } + + /// + /// Creates a database access instance for the specified configuration section. + /// + /// The configuration section that contains database options. + /// Ignored compatibility flag from the legacy toolkit. + /// A instance for the requested database section. + public static IDatabaseAccess Create(string dbSection, bool? useDBAccess2 = null) + { + if (string.IsNullOrEmpty(dbSection)) + { + var rootOptions = TryGetRootOptions(); + dbSection = rootOptions?.SelectedDatabase ?? string.Empty; + } + + if (string.IsNullOrEmpty(dbSection)) + throw new ArgumentException("The database section cannot be null or empty.", nameof(dbSection)); + + var options = Configuration.Configuration.GetSection(dbSection); + return Create(options, useDBAccess2); + } + + /// + /// Creates a database access instance for the specified database options. + /// + /// The database options to use. + /// Ignored compatibility flag from the legacy toolkit. + /// A instance for the supplied options. + public static IDatabaseAccess Create(DatabaseOptions options, bool? useDBAccess2 = null) + { + return new DBAccess(options ?? throw new ArgumentNullException(nameof(options))); + } + + /// + /// Creates the supported database access implementation explicitly. + /// + /// The database options to use. + /// A instance for the supplied options. + public static IDatabaseAccess CreateLegacy(DatabaseOptions options) + { + return Create(options); + } + + /// + /// Creates the supported database access implementation through the legacy DBAccess2 call shape. + /// + /// The database options to use. + /// A instance for the supplied options. + /// + /// DBAccess2 was intentionally not ported into the modern toolkit. This method remains only + /// so callers that reference the old factory shape can compile while receiving DBAccess. + /// + [Obsolete("DBAccess2 is not available in the modern toolkit. Use Create or CreateLegacy instead.")] + public static IDatabaseAccess CreateModern(DatabaseOptions options) + { + return Create(options); + } + + private static DatabaseRootOptions? TryGetRootOptions() + { + try + { + return Configuration.Configuration.GetSection("Data Source"); + } + catch + { + return null; + } + } +} diff --git a/Toolkit.Modern/Data/Database/IDatabaseAccess.cs b/Toolkit.Modern/Data/Database/IDatabaseAccess.cs new file mode 100644 index 0000000..42ef7fa --- /dev/null +++ b/Toolkit.Modern/Data/Database/IDatabaseAccess.cs @@ -0,0 +1,143 @@ +using System; +using System.Data; +using System.Threading; +using System.Threading.Tasks; + +namespace ByteForge.Toolkit.Data +{ + /// + /// Defines the core database access operations shared by legacy and parallel implementations. + /// + public interface IDatabaseAccess + { + /// + /// Gets the database options for this instance. + /// + DatabaseOptions Options { get; } + + /// + /// Gets the configured database type. + /// + DBAccess.DataBaseType DbType { get; } + + /// + /// Gets the connection string for the database. + /// + string ConnectionString { get; } + + /// + /// Gets the number of records affected by the last executed query. + /// + int RecordsAffected { get; } + + /// + /// Gets the last exception that occurred during a database operation. + /// + Exception? LastException { get; } + + /// + /// Tests the database connection. + /// + bool TestConnection(); + + /// + /// Tests the database connection asynchronously. + /// + Task TestConnectionAsync(CancellationToken cancellationToken); + + /// + /// Tries to get a value of type . + /// + bool TryGetValue(out T? value, string query, params object?[]? arguments); + + /// + /// Gets a value of type . + /// + T? GetValue(string query, params object?[]? arguments); + + /// + /// Tries to get a value of type asynchronously. + /// + Task<(bool Success, T? Value)> TryGetValueAsync(string query, CancellationToken cancellationToken, params object?[]? arguments); + + /// + /// Gets a value of type asynchronously. + /// + Task GetValueAsync(string query, CancellationToken cancellationToken, params object?[]? arguments); + + /// + /// Tries to get an untyped value. + /// + bool TryGetValue(out object? value, string query, params object?[]? arguments); + + /// + /// Gets an untyped value. + /// + object? GetValue(string query, params object?[]? arguments); + + /// + /// Tries to get an untyped value asynchronously. + /// + Task<(bool Success, object? Value)> TryGetValueAsync(string query, CancellationToken cancellationToken, params object?[]? arguments); + + /// + /// Gets an untyped value asynchronously. + /// + Task GetValueAsync(string query, CancellationToken cancellationToken, params object?[]? arguments); + + /// + /// Gets the first matching record. + /// + DataRow? GetRecord(string query, params object?[]? arguments); + + /// + /// Gets the first matching record converted to . + /// + T GetRecord(string query, params object?[]? arguments) where T : class, new(); + + /// + /// Gets the first matching record asynchronously. + /// + Task GetRecordAsync(string query, CancellationToken cancellationToken, params object?[]? arguments); + + /// + /// Gets matching records converted to . + /// + T[] GetRecords(string query, params object?[]? arguments) where T : class, new(); + + /// + /// Gets matching records converted to asynchronously. + /// + Task GetRecordsAsync(string query, CancellationToken cancellationToken, params object?[]? arguments) where T : class, new(); + + /// + /// Gets matching records asynchronously. + /// + Task GetRecordsAsync(string query, CancellationToken cancellationToken, params object?[]? arguments); + + /// + /// Gets matching records. + /// + DataRowCollection? GetRecords(string query, params object?[]? arguments); + + /// + /// Executes a non-query asynchronously. + /// + Task ExecuteQueryAsync(string query, CancellationToken cancellationToken, params object?[]? arguments); + + /// + /// Executes a non-query. + /// + bool ExecuteQuery(string query, params object?[]? arguments); + + /// + /// Executes a SQL script. + /// + ScriptExecutionResult ExecuteScript(string script, object[]? arguments = null, bool captureResults = false); + + /// + /// Executes a SQL script asynchronously. + /// + Task ExecuteScriptAsync(string script, CancellationToken cancellationToken, object[]? arguments = null, bool captureResults = false); + } +} diff --git a/Toolkit.Modern/Logging/LogContext.cs b/Toolkit.Modern/Logging/LogContext.cs new file mode 100644 index 0000000..e1c5c21 --- /dev/null +++ b/Toolkit.Modern/Logging/LogContext.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Threading; + +namespace ByteForge.Toolkit.Logging +{ + /// + /// Provides execution context for logging, allowing automatic correlation of log entries + /// with specific operations or requests. Uses AsyncLocal to flow context across async/await boundaries. + /// + public static class LogContext + { + private static readonly AsyncLocal _pageIdentifier = new AsyncLocal(); + private static readonly AsyncLocal _routingContext = new AsyncLocal(); + + /// + /// Gets or sets the current page identifier for this execution context. + /// This value automatically flows across async/await boundaries. + /// + /// + /// The page identifier string (e.g., "ABC1", "Xy3z"), or null if not set. + /// + public static string? PageIdentifier + { + get => _pageIdentifier.Value; + set => _pageIdentifier.Value = value; + } + + /// + /// Gets or sets the current routing context for this execution scope. + /// + public static LogRoutingContext? RoutingContext + { + get => _routingContext.Value; + set => _routingContext.Value = value; + } + + /// + /// Sets the page identifier for the current execution context and returns a disposable scope. + /// When the scope is disposed, the previous page identifier is restored. + /// + /// The page identifier to set. + /// A disposable scope that restores the previous page identifier when disposed. + /// + /// + /// using (LogContext.SetPageIdentifier("ABC1")) + /// { + /// Log.Info("This message will be prefixed with [ABC1]"); + /// await DoWorkAsync(); // PageIdentifier flows through async operations + /// Log.Info("This too will have [ABC1]"); + /// } // Previous PageIdentifier is restored here + /// + /// + public static IDisposable SetPageIdentifier(string pageId) + { + var previous = _pageIdentifier.Value; + _pageIdentifier.Value = pageId; + return new Scope(() => _pageIdentifier.Value = previous); + } + + /// + /// Applies scoped log routing for the current execution context. + /// + /// Additional loggers that should receive entries in this scope. + /// Logger names that should not receive entries in this scope. + /// A disposable scope that restores the previous routing context. + public static IDisposable BeginRoutingScope(IEnumerable additionalLoggers = null, IEnumerable suppressedLoggerNames = null) + { + var previous = _routingContext.Value; + var next = new LogRoutingContext(additionalLoggers, suppressedLoggerNames); + _routingContext.Value = previous?.Merge(next) ?? next; + return new Scope(() => _routingContext.Value = previous); + } + + /// + /// Clears the page identifier from the current execution context. + /// + public static void Clear() + { + _pageIdentifier.Value = null; + _routingContext.Value = null; + } + + /// + /// Internal helper class that implements IDisposable to restore context on scope exit. + /// + private class Scope : IDisposable + { + private readonly Action _onDispose; + private bool _disposed; + + public Scope(Action onDispose) + { + _onDispose = onDispose; + } + + public void Dispose() + { + if (!_disposed) + { + _onDispose?.Invoke(); + _disposed = true; + } + } + } + } +} diff --git a/Toolkit.Modern/Logging/LogSecretMasker.cs b/Toolkit.Modern/Logging/LogSecretMasker.cs new file mode 100644 index 0000000..936c6d9 --- /dev/null +++ b/Toolkit.Modern/Logging/LogSecretMasker.cs @@ -0,0 +1,40 @@ +using System; +using System.Text.RegularExpressions; + +namespace ByteForge.Toolkit.Logging +{ + /// + /// Masks sensitive key/value pairs before they are written to log destinations. + /// + public static class LogSecretMasker + { + private const string MaskValue = "[REDACTED]"; + private const string SensitiveKeyPattern = + "password|passwd|pwd|secret|client[_-]?secret|clientsecret|api[_-]?key|apikey|api[_-]?token|apitoken|access[_-]?token|accesstoken|refresh[_-]?token|refreshtoken"; + + private static readonly Regex SensitiveKeyValuePattern = new Regex( + $@"(?(?:[""']?(?:{SensitiveKeyPattern})[""']?\s*[:=]\s*[""']?))(?[^""'\s,;&}}]+)(?[""']?)", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + /// + /// Replaces sensitive values in the supplied log text with a fixed redaction marker. + /// + /// The text to scan for sensitive key/value pairs. + /// The original text with sensitive values masked, or the original value when no masking is needed. + public static string Mask(string text) + { + if (string.IsNullOrEmpty(text)) + return text; + + return SensitiveKeyValuePattern.Replace(text, match => + $"{match.Groups["prefix"].Value}{MaskValue}{match.Groups["suffix"].Value}"); + } + + /// + /// Replaces sensitive values in the supplied object value with a fixed redaction marker. + /// + /// The value to convert to text and scan for sensitive key/value pairs. + /// The string representation of the value with sensitive values masked. + public static string Mask(object value) => value == null ? null : Mask(value.ToString()); + } +} diff --git a/Toolkit.Modern/Logging/Models/AsyncLoggerOptions.cs b/Toolkit.Modern/Logging/Models/AsyncLoggerOptions.cs new file mode 100644 index 0000000..752e2ad --- /dev/null +++ b/Toolkit.Modern/Logging/Models/AsyncLoggerOptions.cs @@ -0,0 +1,38 @@ +using System.ComponentModel; + +namespace ByteForge.Toolkit.Logging +{ + /* + * _ _ ___ _ _ + * /_\ ____ _ _ _ __| | ___ __ _ __ _ ___ _ _ / _ \ _ __| |_(_)___ _ _ ___ + * / _ \ (_-< || | ' \/ _| |__/ _ \/ _` / _` / -_) '_| (_) | '_ \ _| / _ \ ' \(_-< + * /_/ \_\/__/\_, |_||_\__|____\___/\__, \__, \___|_| \___/| .__/\__|_\___/_||_/__/ + * |__/ |___/|___/ |_| + */ + /// + /// Provides base options for asynchronous logging configuration. + /// + public abstract class AsyncLoggerOptions + { + private const bool DefaultUseAsyncLogging = false; + private const int DefaultAsyncQueueSize = 1000; + + /// + /// Initializes a new instance of the class. + /// + protected AsyncLoggerOptions() { } + + /// + /// Gets or sets a value indicating whether to use asynchronous logging. + /// + [DefaultValue(DefaultUseAsyncLogging)] + public bool UseAsyncLogging { get; set; } = DefaultUseAsyncLogging; + + /// + /// Gets or sets the size of the asynchronous logging queue. + /// The default value is 1000. + /// + [DefaultValue(DefaultAsyncQueueSize)] + public int AsyncQueueSize { get; set; } = DefaultAsyncQueueSize; + } +} diff --git a/Toolkit.Modern/Logging/Models/LogRoutingContext.cs b/Toolkit.Modern/Logging/Models/LogRoutingContext.cs new file mode 100644 index 0000000..eadc0e4 --- /dev/null +++ b/Toolkit.Modern/Logging/Models/LogRoutingContext.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ByteForge.Toolkit.Logging +{ + /// + /// Represents a scoped routing snapshot for a log entry. + /// + public sealed class LogRoutingContext + { + /// + /// Initializes a new instance of the class. + /// + /// Additional loggers that should receive the entry. + /// Logger names that should not receive the entry. + public LogRoutingContext(IEnumerable additionalLoggers = null, IEnumerable suppressedLoggerNames = null) + { + AdditionalLoggers = (additionalLoggers ?? Array.Empty()) + .Where(logger => logger != null) + .ToArray(); + + SuppressedLoggerNames = new HashSet( + (suppressedLoggerNames ?? Array.Empty()) + .Where(name => string.IsNullOrWhiteSpace(name) == false), + StringComparer.OrdinalIgnoreCase); + } + + /// + /// Gets the additional loggers that should receive the entry. + /// + public IReadOnlyCollection AdditionalLoggers { get; } + + /// + /// Gets the logger names that should not receive the entry. + /// + public IReadOnlyCollection SuppressedLoggerNames { get; } + + /// + /// Merges this routing snapshot with another snapshot. + /// + /// The other snapshot. + /// A merged routing snapshot. + public LogRoutingContext Merge(LogRoutingContext other) + { + if (other == null) + return this; + + return new LogRoutingContext( + AdditionalLoggers.Concat(other.AdditionalLoggers), + SuppressedLoggerNames.Concat(other.SuppressedLoggerNames)); + } + + /// + /// Determines whether the specified logger should be suppressed. + /// + /// The logger to evaluate. + /// when the logger should be suppressed; otherwise, . + public bool IsSuppressed(ILogger logger) + { + if (logger == null || string.IsNullOrWhiteSpace(logger.Name)) + return false; + + return SuppressedLoggerNames.Contains(logger.Name); + } + } +} diff --git a/Toolkit.Modern/Utilities/ReportTimestampFormatter.cs b/Toolkit.Modern/Utilities/ReportTimestampFormatter.cs new file mode 100644 index 0000000..983d39d --- /dev/null +++ b/Toolkit.Modern/Utilities/ReportTimestampFormatter.cs @@ -0,0 +1,223 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace ByteForge.Toolkit.Utilities +{ + /// + /// Formats report timestamps with an explicit time zone label. + /// + public static class ReportTimestampFormatter + { + private static readonly Dictionary WindowsToIanaTimeZones = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Eastern Standard Time"] = "America/New_York", + ["Central Standard Time"] = "America/Chicago", + ["Mountain Standard Time"] = "America/Denver", + ["Pacific Standard Time"] = "America/Los_Angeles", + ["Alaskan Standard Time"] = "America/Anchorage", + ["Hawaiian Standard Time"] = "Pacific/Honolulu", + ["US Eastern Standard Time"] = "America/Indianapolis", + ["US Mountain Standard Time"] = "America/Phoenix", + ["Canada Central Standard Time"] = "America/Regina", + ["Atlantic Standard Time"] = "America/Halifax", + ["Newfoundland Standard Time"] = "America/St_Johns", + ["E. South America Standard Time"] = "America/Sao_Paulo" + }; + + /// + /// Formats the current local timestamp with a time zone label. + /// + /// The date and time format to use before the time zone label. + /// The formatted local timestamp. + public static string FormatLocalNow(string format) + { + return FormatLocal(DateTime.Now, format); + } + + /// + /// Formats a local timestamp with the local time zone label. + /// + /// The local timestamp to format. + /// The date and time format to use before the time zone label. + /// The formatted timestamp. + public static string FormatLocal(DateTime value, string format) + { + return Format(value, format, TimeZoneInfo.Local); + } + + /// + /// Formats the current timestamp in the default report time zone. + /// + /// The date and time format to use before the time zone label. + /// The formatted current UTC report timestamp. + public static string FormatDefaultReportNow(string format) + { + return FormatUtcNow(format); + } + + /// + /// Formats a timestamp in the default report time zone. + /// + /// The timestamp to format. Unspecified values are assumed to already be UTC. + /// The date and time format to use before the time zone label. + /// The formatted UTC report timestamp. + public static string FormatDefaultReport(DateTime value, string format) + { + return FormatUtc(value, format); + } + + /// + /// Formats a nullable timestamp in the default report time zone. + /// + /// The timestamp to format. Unspecified values are assumed to already be UTC. + /// The date and time format to use before the time zone label. + /// The value to return when the timestamp is null. + /// The formatted UTC report timestamp, or the empty value. + public static string FormatDefaultReport(DateTime? value, string format, string emptyValue) + { + return value.HasValue ? FormatDefaultReport(value.Value, format) : emptyValue; + } + + /// + /// Formats the current timestamp in a named time zone. + /// + /// The Windows time zone ID to use. + /// The date and time format to use before the time zone label. + /// The formatted timestamp in the named time zone. + public static string FormatTimeZoneNow(string timeZoneId, string format) + { + return FormatTimeZone(DateTime.UtcNow, timeZoneId, format); + } + + /// + /// Formats a timestamp in a named time zone. + /// + /// The timestamp to format. Unspecified values are assumed to already be UTC. + /// The Windows time zone ID to use. + /// The date and time format to use before the time zone label. + /// The formatted timestamp in the named time zone. + public static string FormatTimeZone(DateTime value, string timeZoneId, string format) + { + var timeZone = ResolveTimeZone(timeZoneId); + var utcValue = value.Kind == DateTimeKind.Local + ? value.ToUniversalTime() + : DateTime.SpecifyKind(value, DateTimeKind.Utc); + + return Format(TimeZoneInfo.ConvertTimeFromUtc(utcValue, timeZone), format, timeZone); + } + + /// + /// Formats the current UTC timestamp with the UTC time zone label. + /// + /// The date and time format to use before the time zone label. + /// The formatted current UTC timestamp. + public static string FormatUtcNow(string format) + { + return FormatUtc(DateTime.UtcNow, format); + } + + /// + /// Formats a UTC timestamp with the UTC time zone label. + /// + /// The UTC timestamp to format. Unspecified values are assumed to already be UTC. + /// The date and time format to use before the time zone label. + /// The formatted UTC timestamp. + public static string FormatUtc(DateTime value, string format) + { + var utcValue = value.Kind == DateTimeKind.Local + ? value.ToUniversalTime() + : DateTime.SpecifyKind(value, DateTimeKind.Utc); + + return string.Format( + CultureInfo.InvariantCulture, + "{0} UTC", + utcValue.ToString(format, CultureInfo.InvariantCulture)); + } + + /// + /// Formats a timestamp with the supplied time zone label. + /// + /// The timestamp to format. + /// The date and time format to use before the time zone label. + /// The time zone that describes the timestamp. + /// The formatted timestamp. + public static string Format(DateTime value, string format, TimeZoneInfo timeZone) + { + var resolvedTimeZone = timeZone ?? TimeZoneInfo.Local; + return string.Format( + CultureInfo.InvariantCulture, + "{0} {1}", + value.ToString(format, CultureInfo.InvariantCulture), + GetTimeZoneLabel(resolvedTimeZone, value)); + } + + /// + /// Gets the preferred report label for a time zone. + /// + /// The time zone to label. + /// The timestamp used to choose a daylight-aware abbreviation fallback. + /// The time zone label. + public static string GetTimeZoneLabel(TimeZoneInfo timeZone, DateTime timestamp) + { + if (timeZone == null) + return TimeZoneInfo.Local.Id; + + if (string.Equals(timeZone.Id, "UTC", StringComparison.OrdinalIgnoreCase)) + return "UTC"; + + if (WindowsToIanaTimeZones.TryGetValue(timeZone.Id, out var ianaName)) + return ianaName; + + var abbreviation = GetCommonAbbreviation(timeZone, timestamp); + return string.IsNullOrWhiteSpace(abbreviation) ? timeZone.Id : abbreviation; + } + + /// + /// Gets a common daylight-aware abbreviation for unmapped zones. + /// + /// The time zone to abbreviate. + /// The timestamp used to choose daylight or standard time. + /// The abbreviation, or an empty string when no abbreviation is known. + private static string GetCommonAbbreviation(TimeZoneInfo timeZone, DateTime timestamp) + { + if (timeZone == null) + return string.Empty; + + switch (timeZone.Id) + { + case "Eastern Standard Time": + return timeZone.IsDaylightSavingTime(timestamp) ? "EDT" : "EST"; + case "Central Standard Time": + return timeZone.IsDaylightSavingTime(timestamp) ? "CDT" : "CST"; + case "Mountain Standard Time": + return timeZone.IsDaylightSavingTime(timestamp) ? "MDT" : "MST"; + case "Pacific Standard Time": + return timeZone.IsDaylightSavingTime(timestamp) ? "PDT" : "PST"; + default: + return string.Empty; + } + } + + /// + /// Resolves a time zone by ID. + /// + /// The Windows time zone ID to resolve. + /// The resolved time zone, or UTC if the ID is unavailable. + private static TimeZoneInfo ResolveTimeZone(string timeZoneId) + { + try + { + return TimeZoneInfo.FindSystemTimeZoneById(timeZoneId); + } + catch (TimeZoneNotFoundException) + { + return TimeZoneInfo.Utc; + } + catch (InvalidTimeZoneException) + { + return TimeZoneInfo.Utc; + } + } + } +} From fa572e51ee5faef5d8945f1c826b3fb04177fe9f Mon Sep 17 00:00:00 2001 From: Paulo Santos Date: Sun, 17 May 2026 12:00:52 -0300 Subject: [PATCH 3/5] test(toolkit): cover parity edge cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ● Add tests for factory shims, logging context, secret masking, and timestamp formatting. ● Cover provider attribute validation and assembly metadata constructors. --- .../Unit/AssemblyMetadataAttributeTests.cs | 21 ++++ .../ConfigOptionsProviderAttributeTests.cs | 57 ++++++++++ .../Database/DatabaseAccessFactoryTests.cs | 83 ++++++++++++++ .../Unit/Logging/LogContextTests.cs | 106 ++++++++++++++++++ .../Unit/Logging/LogSecretMaskerTests.cs | 55 +++++++++ .../Utils/ReportTimestampFormatterTests.cs | 65 +++++++++++ 6 files changed, 387 insertions(+) create mode 100644 Toolkit.Modern.Tests/Unit/AssemblyMetadataAttributeTests.cs create mode 100644 Toolkit.Modern.Tests/Unit/Configuration/ConfigOptionsProviderAttributeTests.cs create mode 100644 Toolkit.Modern.Tests/Unit/Data/Database/DatabaseAccessFactoryTests.cs create mode 100644 Toolkit.Modern.Tests/Unit/Logging/LogContextTests.cs create mode 100644 Toolkit.Modern.Tests/Unit/Logging/LogSecretMaskerTests.cs create mode 100644 Toolkit.Modern.Tests/Unit/Utils/ReportTimestampFormatterTests.cs diff --git a/Toolkit.Modern.Tests/Unit/AssemblyMetadataAttributeTests.cs b/Toolkit.Modern.Tests/Unit/AssemblyMetadataAttributeTests.cs new file mode 100644 index 0000000..5856b1f --- /dev/null +++ b/Toolkit.Modern.Tests/Unit/AssemblyMetadataAttributeTests.cs @@ -0,0 +1,21 @@ +using AwesomeAssertions; + +namespace ByteForge.Toolkit.Tests.Unit +{ + [TestClass] + [TestCategory("Unit")] + public class AssemblyMetadataAttributeTests + { + /// + /// Verifies that assembly developer metadata attributes preserve constructor values. + /// + [TestMethod] + public void AssemblyDeveloperAttributes_ShouldExposeConfiguredValues() + { + new AssemblyDeveloperAttribute("Paulo Santos").Name.Should().Be("Paulo Santos"); + new AssemblyDeveloperCompanyAttribute("ByteForge, LLC.").Name.Should().Be("ByteForge, LLC."); + new AssemblyCompanyUrlAttribute("https://byteforge.example").Url.Should().Be("https://byteforge.example"); + new AssemblyDeveloperCompanyUrlAttribute("https://dev.example").Url.Should().Be("https://dev.example"); + } + } +} diff --git a/Toolkit.Modern.Tests/Unit/Configuration/ConfigOptionsProviderAttributeTests.cs b/Toolkit.Modern.Tests/Unit/Configuration/ConfigOptionsProviderAttributeTests.cs new file mode 100644 index 0000000..4bd17b8 --- /dev/null +++ b/Toolkit.Modern.Tests/Unit/Configuration/ConfigOptionsProviderAttributeTests.cs @@ -0,0 +1,57 @@ +using AwesomeAssertions; +using ByteForge.Toolkit.Configuration; +using System.Reflection; + +namespace ByteForge.Toolkit.Tests.Unit.Configuration +{ + [TestClass] + [TestCategory("Unit")] + [TestCategory("Configuration")] + public class ConfigOptionsProviderAttributeTests + { + /// + /// Verifies that provider attributes create the configured provider instance. + /// + [TestMethod] + public void CreateProvider_WithValidProviderType_ShouldCreateProvider() + { + var attribute = new ConfigOptionsProviderAttribute(typeof(TestOptionsProvider)); + + var provider = attribute.CreateProvider(); + + provider.Should().BeOfType(); + } + + /// + /// Verifies that provider attributes reject non-provider types. + /// + [TestMethod] + public void Constructor_WithInvalidProviderType_ShouldThrowArgumentException() + { + Action action = () => new ConfigOptionsProviderAttribute(typeof(string)); + + action.Should().Throw() + .WithParameterName("providerType"); + } + + /// + /// Verifies that provider attributes reject null provider types. + /// + [TestMethod] + public void Constructor_WithNullProviderType_ShouldThrowArgumentNullException() + { + Action action = () => new ConfigOptionsProviderAttribute(null); + + action.Should().Throw() + .WithParameterName("providerType"); + } + + private sealed class TestOptionsProvider : IConfigOptionsProvider + { + public IReadOnlyCollection GetOptions(PropertyInfo property) + { + return Array.Empty(); + } + } + } +} diff --git a/Toolkit.Modern.Tests/Unit/Data/Database/DatabaseAccessFactoryTests.cs b/Toolkit.Modern.Tests/Unit/Data/Database/DatabaseAccessFactoryTests.cs new file mode 100644 index 0000000..0fa8947 --- /dev/null +++ b/Toolkit.Modern.Tests/Unit/Data/Database/DatabaseAccessFactoryTests.cs @@ -0,0 +1,83 @@ +using AwesomeAssertions; +using ByteForge.Toolkit.Data; +using ByteForge.Toolkit.Tests.Helpers; + +namespace ByteForge.Toolkit.Tests.Unit.Data.Database +{ + [TestClass] + [TestCategory("Unit")] + [TestCategory("Data")] + public class DatabaseAccessFactoryTests + { + /// + /// Verifies that the factory returns DBAccess even when the legacy DBAccess2 flag is requested. + /// + [TestMethod] + public void Create_WithUseDBAccess2Flag_ShouldReturnDBAccess() + { + var options = DatabaseTestHelper.CreateTestDatabaseOptions(); + + var result = DatabaseAccessFactory.Create(options, useDBAccess2: true); + + result.Should().BeOfType(); + result.Options.Should().BeSameAs(options); + } + + /// + /// Verifies that the obsolete modern factory shape remains a DBAccess compatibility shim. + /// + [TestMethod] + public void CreateModern_ShouldReturnDBAccessCompatibilityShim() + { + var options = DatabaseTestHelper.CreateTestDatabaseOptions(); + +#pragma warning disable CS0618 + var result = DatabaseAccessFactory.CreateModern(options); +#pragma warning restore CS0618 + + result.Should().BeOfType(); + result.Options.Should().BeSameAs(options); + } + + /// + /// Verifies that null options are rejected consistently. + /// + [TestMethod] + public void Create_WithNullOptions_ShouldThrowArgumentNullException() + { + Action action = () => DatabaseAccessFactory.Create((DatabaseOptions)null); + + action.Should().Throw() + .WithParameterName("options"); + } + + /// + /// Verifies that DBAccess can be consumed through the compatibility interface. + /// + [TestMethod] + public void DBAccess_ShouldImplementIDatabaseAccess() + { + var options = DatabaseTestHelper.CreateTestDatabaseOptions(); + + var result = new DBAccess(options); + + result.Should().BeAssignableTo(); + ((IDatabaseAccess)result).ConnectionString.Should().Be(options.GetConnectionString()); + } + + /// + /// Verifies that cancellation-token compatibility overloads honor pre-canceled tokens. + /// + [TestMethod] + public void CompatibilityAsyncOverloads_WithCanceledToken_ShouldThrowOperationCanceledException() + { + var access = (IDatabaseAccess)new DBAccess(DatabaseTestHelper.CreateTestDatabaseOptions()); + using var source = new CancellationTokenSource(); + source.Cancel(); + + Action action = () => access.ExecuteQueryAsync("SELECT 1", source.Token).GetAwaiter().GetResult(); + + action.Should().Throw(); + } + } +} diff --git a/Toolkit.Modern.Tests/Unit/Logging/LogContextTests.cs b/Toolkit.Modern.Tests/Unit/Logging/LogContextTests.cs new file mode 100644 index 0000000..6b60ff6 --- /dev/null +++ b/Toolkit.Modern.Tests/Unit/Logging/LogContextTests.cs @@ -0,0 +1,106 @@ +using AwesomeAssertions; +using ByteForge.Toolkit.Logging; + +namespace ByteForge.Toolkit.Tests.Unit.Logging +{ + [TestClass] + [TestCategory("Unit")] + [TestCategory("Logging")] + public class LogContextTests + { + [TestCleanup] + public void Cleanup() + { + LogContext.Clear(); + } + + /// + /// Verifies that page identifier scopes restore the previous value. + /// + [TestMethod] + public void SetPageIdentifier_WithNestedScopes_ShouldRestorePreviousValues() + { + LogContext.PageIdentifier = "outer"; + + using (LogContext.SetPageIdentifier("inner")) + { + LogContext.PageIdentifier.Should().Be("inner"); + } + + LogContext.PageIdentifier.Should().Be("outer"); + } + + /// + /// Verifies that routing scopes merge and restore logging context. + /// + [TestMethod] + public void BeginRoutingScope_WithNestedScopes_ShouldMergeAndRestoreContext() + { + var loggerA = new TestLogger("A"); + var loggerB = new TestLogger("B"); + + using (LogContext.BeginRoutingScope(new[] { loggerA }, new[] { "File" })) + { + LogContext.RoutingContext.AdditionalLoggers.Should().ContainSingle().Which.Should().BeSameAs(loggerA); + LogContext.RoutingContext.SuppressedLoggerNames.Should().Contain("File"); + + using (LogContext.BeginRoutingScope(new[] { loggerB }, new[] { "Console" })) + { + LogContext.RoutingContext.AdditionalLoggers.Should().Contain(new[] { loggerA, loggerB }); + LogContext.RoutingContext.SuppressedLoggerNames.Should().Contain(new[] { "File", "Console" }); + } + + LogContext.RoutingContext.AdditionalLoggers.Should().ContainSingle().Which.Should().BeSameAs(loggerA); + LogContext.RoutingContext.SuppressedLoggerNames.Should().Contain("File"); + LogContext.RoutingContext.SuppressedLoggerNames.Should().NotContain("Console"); + } + + LogContext.RoutingContext.Should().BeNull(); + } + + /// + /// Verifies logger suppression is case-insensitive and null-safe. + /// + [TestMethod] + public void IsSuppressed_ShouldUseCaseInsensitiveLoggerNames() + { + var context = new LogRoutingContext(suppressedLoggerNames: new[] { "File" }); + + context.IsSuppressed(new TestLogger("file")).Should().BeTrue(); + context.IsSuppressed(new TestLogger("Console")).Should().BeFalse(); + context.IsSuppressed(null).Should().BeFalse(); + } + + private sealed class TestLogger : ILogger + { + public TestLogger(string name) + { + Name = name; + } + + public string Name { get; set; } + + public LogLevel MinLogLevel { get; set; } + + public void Log(LogLevel level, string message, Exception ex = null) { } + + public void LogTrace(string message) { } + + public void LogDebug(string message) { } + + public void LogVerbose(string message) { } + + public void LogInfo(string message) { } + + public void LogNotice(string message) { } + + public void LogWarning(string message) { } + + public void LogError(string message, Exception ex = null) { } + + public void LogCritical(string message, Exception ex = null) { } + + public void LogFatal(string message, Exception ex = null) { } + } + } +} diff --git a/Toolkit.Modern.Tests/Unit/Logging/LogSecretMaskerTests.cs b/Toolkit.Modern.Tests/Unit/Logging/LogSecretMaskerTests.cs new file mode 100644 index 0000000..7e13d3b --- /dev/null +++ b/Toolkit.Modern.Tests/Unit/Logging/LogSecretMaskerTests.cs @@ -0,0 +1,55 @@ +using AwesomeAssertions; +using ByteForge.Toolkit.Logging; + +namespace ByteForge.Toolkit.Tests.Unit.Logging +{ + [TestClass] + [TestCategory("Unit")] + [TestCategory("Logging")] + public class LogSecretMaskerTests + { + /// + /// Verifies that common secret key shapes are redacted. + /// + [TestMethod] + public void Mask_WithKnownSecretKeys_ShouldRedactValues() + { + var input = "password=hunter2 client_secret='abc123' AccessToken:xyz refresh_token=refreshSecret"; + + var result = LogSecretMasker.Mask(input); + + result.Should().Contain("password=[REDACTED]"); + result.Should().Contain("client_secret='[REDACTED]'"); + result.Should().Contain("AccessToken:[REDACTED]"); + result.Should().Contain("refresh_token=[REDACTED]"); + result.Should().NotContain("hunter2"); + result.Should().NotContain("abc123"); + result.Should().NotContain("xyz"); + result.Should().NotContain("refreshSecret"); + } + + /// + /// Verifies that non-sensitive text is not changed. + /// + [TestMethod] + public void Mask_WithNoSecretKeys_ShouldReturnOriginalText() + { + var input = "status=ok user=paul tokenized=false"; + + var result = LogSecretMasker.Mask(input); + + result.Should().Be(input); + } + + /// + /// Verifies that null object input preserves legacy null behavior. + /// + [TestMethod] + public void Mask_WithNullObject_ShouldReturnNull() + { + var result = LogSecretMasker.Mask((object)null); + + result.Should().BeNull(); + } + } +} diff --git a/Toolkit.Modern.Tests/Unit/Utils/ReportTimestampFormatterTests.cs b/Toolkit.Modern.Tests/Unit/Utils/ReportTimestampFormatterTests.cs new file mode 100644 index 0000000..486664b --- /dev/null +++ b/Toolkit.Modern.Tests/Unit/Utils/ReportTimestampFormatterTests.cs @@ -0,0 +1,65 @@ +using AwesomeAssertions; +using ByteForge.Toolkit.Utilities; + +namespace ByteForge.Toolkit.Tests.Unit.Utils +{ + [TestClass] + [TestCategory("Unit")] + [TestCategory("Utils")] + public class ReportTimestampFormatterTests + { + /// + /// Verifies that UTC timestamps are formatted with an explicit UTC label. + /// + [TestMethod] + public void FormatUtc_WithUnspecifiedDateTime_ShouldTreatValueAsUtc() + { + var value = new DateTime(2026, 5, 17, 14, 30, 0, DateTimeKind.Unspecified); + + var result = ReportTimestampFormatter.FormatUtc(value, "yyyy-MM-dd HH:mm"); + + result.Should().Be("2026-05-17 14:30 UTC"); + } + + /// + /// Verifies that nullable default report formatting uses the supplied empty value. + /// + [TestMethod] + public void FormatDefaultReport_WithNullValue_ShouldReturnEmptyValue() + { + var result = ReportTimestampFormatter.FormatDefaultReport(null, "yyyy-MM-dd", "n/a"); + + result.Should().Be("n/a"); + } + + /// + /// Verifies that known Windows time zone IDs are rendered as stable IANA labels. + /// + [TestMethod] + public void GetTimeZoneLabel_WithKnownWindowsZone_ShouldReturnIanaLabel() + { + var timeZone = TimeZoneInfo.CreateCustomTimeZone( + "Eastern Standard Time", + TimeSpan.FromHours(-5), + "Eastern", + "Eastern"); + + var result = ReportTimestampFormatter.GetTimeZoneLabel(timeZone, new DateTime(2026, 1, 15)); + + result.Should().Be("America/New_York"); + } + + /// + /// Verifies that unavailable time zones fall back to UTC instead of throwing. + /// + [TestMethod] + public void FormatTimeZone_WithUnknownTimeZone_ShouldFallBackToUtc() + { + var value = new DateTime(2026, 5, 17, 14, 30, 0, DateTimeKind.Utc); + + var result = ReportTimestampFormatter.FormatTimeZone(value, "No Such Zone", "yyyy-MM-dd HH:mm"); + + result.Should().Be("2026-05-17 14:30 UTC"); + } + } +} From 802c83432a44ca2aedda078522a39bc32b943216 Mon Sep 17 00:00:00 2001 From: Paulo Santos Date: Sun, 17 May 2026 12:01:07 -0300 Subject: [PATCH 4/5] chore(repo): generalize update script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ● Allow configurable base branches, remotes, and optional push behavior. ● Harden cleanup when git operations or stash restoration cannot safely continue. --- Update-FromMaster.ps1 | 180 +++++++++++++++++++++++++++++++++--------- 1 file changed, 143 insertions(+), 37 deletions(-) diff --git a/Update-FromMaster.ps1 b/Update-FromMaster.ps1 index 6011323..b95fcce 100644 --- a/Update-FromMaster.ps1 +++ b/Update-FromMaster.ps1 @@ -3,11 +3,21 @@ param( [ValidateSet('merge', 'rebase')] [string]$Mode = 'rebase', + [string]$BaseBranch, + + [string]$RemoteName = 'origin', + + [switch]$NoPush, + [switch]$KeepTempFile ) $ErrorActionPreference = 'Stop' +if ($IsWindows -eq $false) { + throw "This script requires a Windows environment. The temporary batch runner (.bat) is not supported on non-Windows platforms." +} + function Assert-GitSuccess { param( [Parameter(Mandatory = $true)] @@ -103,6 +113,16 @@ function Get-TrimmedGitOutput { return ([string]$output).Trim() } +function Test-GitRefExists { + param( + [Parameter(Mandatory = $true)] + [string]$RefName + ) + + git -C $repoRoot show-ref --verify --quiet $RefName + return $LASTEXITCODE -eq 0 +} + function Test-BuildArtifactStatus { param( [Parameter(Mandatory = $true)] @@ -152,6 +172,56 @@ function Get-BuildArtifactDirectory { return "$prefix/$folderName" } +function Get-BaseBranchNameFromRef { + param( + [Parameter(Mandatory = $true)] + [string]$RefName + ) + + $pathParts = $RefName -split '/' + if ($pathParts.Count -eq 0) { + return $null + } + + return $pathParts[-1] +} + +function Resolve-BaseBranch { + if (-not [string]::IsNullOrWhiteSpace($BaseBranch)) { + return $BaseBranch.Trim() + } + + $remoteHeadRef = git -C $repoRoot symbolic-ref --quiet "refs/remotes/$RemoteName/HEAD" + if ($LASTEXITCODE -eq 0 -and -not [string]::IsNullOrWhiteSpace($remoteHeadRef)) { + return Get-BaseBranchNameFromRef ([string]$remoteHeadRef).Trim() + } + + $candidateRefs = @( + "refs/heads/master", + "refs/heads/main", + "refs/remotes/$RemoteName/master", + "refs/remotes/$RemoteName/main" + ) + + foreach ($candidateRef in $candidateRefs) { + if (Test-GitRefExists $candidateRef) { + return Get-BaseBranchNameFromRef $candidateRef + } + } + + throw "Unable to determine the base branch automatically. Specify -BaseBranch explicitly." +} + +function Test-AnyGitState { + foreach ($stateFile in $stateFiles) { + if (Test-GitStateFile $stateFile) { + return $true + } + } + + return $false +} + $repoRoot = (Resolve-Path $PSScriptRoot).Path $tempBat = $null $stashCreated = $false @@ -162,14 +232,25 @@ $exitCode = 0 git -C $repoRoot rev-parse --show-toplevel | Out-Null Assert-GitSuccess "This script must be run from inside a git repository." +git -C $repoRoot remote get-url $RemoteName | Out-Null +Assert-GitSuccess "Remote '$RemoteName' was not found." + $currentBranch = Get-TrimmedGitOutput @('branch', '--show-current') if ([string]::IsNullOrWhiteSpace($currentBranch)) { throw "Detached HEAD is not supported. Check out a branch first." } -if ($currentBranch -eq 'master') { - throw "You are already on 'master'. Switch to a feature branch before running this script." +$resolvedBaseBranch = Resolve-BaseBranch +$baseBranchRef = "refs/heads/$resolvedBaseBranch" +$remoteBaseBranchRef = "refs/remotes/$RemoteName/$resolvedBaseBranch" + +if (-not (Test-GitRefExists $baseBranchRef) -and -not (Test-GitRefExists $remoteBaseBranchRef)) { + throw "Base branch '$resolvedBaseBranch' was not found locally. Fetch it or specify a different -BaseBranch." +} + +if ($currentBranch.Equals($resolvedBaseBranch, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "You are already on '$resolvedBaseBranch'. Switch to a feature branch before running this script." } $gitDir = (git -C $repoRoot rev-parse --git-dir).Trim() @@ -253,9 +334,12 @@ if ($stashRelevantStatus.Count -gt 0) { $stashCreated = $true } -$tempBat = Join-Path ([System.IO.Path]::GetTempPath()) ("update-from-master-{0}.bat" -f ([guid]::NewGuid().ToString('N'))) +$tempBat = Join-Path ([System.IO.Path]::GetTempPath()) ("update-from-base-{0}.bat" -f ([guid]::NewGuid().ToString('N'))) $repoRootEscaped = $repoRoot.Replace('%', '%%').Replace('"', '""') -$currentBranchEscaped = $currentBranch.Replace('"', '""') +$currentBranchEscaped = $currentBranch.Replace('%', '%%').Replace('"', '""') +$baseBranchEscaped = $resolvedBaseBranch.Replace('%', '%%').Replace('"', '""') +$remoteNameEscaped = $RemoteName.Replace('%', '%%').Replace('"', '""') +$pushChanges = if ($NoPush) { 'false' } else { 'true' } $batchContent = @" @echo off @@ -263,19 +347,22 @@ setlocal set "REPO_ROOT=$repoRootEscaped" set "ORIGINAL_BRANCH=$currentBranchEscaped" +set "BASE_BRANCH=$baseBranchEscaped" +set "REMOTE_NAME=$remoteNameEscaped" set "MODE=$Mode" +set "PUSH_CHANGES=$pushChanges" cd /d "%REPO_ROOT%" if errorlevel 1 exit /b 1 echo. -echo ^> git switch master -git switch master +echo ^> git switch "%BASE_BRANCH%" +git switch -- "%BASE_BRANCH%" if errorlevel 1 exit /b 1 echo. -echo ^> git pull --ff-only origin master -git pull --ff-only origin master +echo ^> git pull --ff-only "%REMOTE_NAME%" "%BASE_BRANCH%" +git pull --ff-only "%REMOTE_NAME%" "%BASE_BRANCH%" if errorlevel 1 exit /b 1 echo. @@ -285,23 +372,28 @@ if errorlevel 1 exit /b 1 echo. if /i "%MODE%"=="rebase" ( - echo ^> git rebase master - git rebase master + echo ^> git rebase "%BASE_BRANCH%" + git rebase "%BASE_BRANCH%" ) else ( - echo ^> git merge master - git merge master + echo ^> git merge "%BASE_BRANCH%" + git merge "%BASE_BRANCH%" ) if errorlevel 1 exit /b 1 -echo. -if /i "%MODE%"=="rebase" ( - echo ^> git push --force-with-lease origin "%ORIGINAL_BRANCH%" - git push --force-with-lease origin "%ORIGINAL_BRANCH%" +if /i "%PUSH_CHANGES%"=="true" ( + echo. + if /i "%MODE%"=="rebase" ( + echo ^> git push --force-with-lease "%REMOTE_NAME%" "%ORIGINAL_BRANCH%" + git push --force-with-lease "%REMOTE_NAME%" "%ORIGINAL_BRANCH%" + ) else ( + echo ^> git push "%REMOTE_NAME%" "%ORIGINAL_BRANCH%" + git push "%REMOTE_NAME%" "%ORIGINAL_BRANCH%" + ) + if errorlevel 1 exit /b 1 ) else ( - echo ^> git push origin "%ORIGINAL_BRANCH%" - git push origin "%ORIGINAL_BRANCH%" + echo. + echo ^> Push skipped because -NoPush was specified. ) -if errorlevel 1 exit /b 1 echo. echo Done. @@ -311,7 +403,10 @@ exit /b 0 Set-Content -LiteralPath $tempBat -Value $batchContent -Encoding Default Write-Host "Original branch: $currentBranch" +Write-Host "Base branch: $resolvedBaseBranch" Write-Host "Integration mode: $Mode" +Write-Host "Remote: $RemoteName" +Write-Host "Push changes: $(-not $NoPush)" Write-Host "Temporary runner: $tempBat" if ($stashCreated) { @@ -333,34 +428,43 @@ finally { Write-Warning "Unable to determine the current branch during cleanup. Continuing with stash restoration and temp-file cleanup." } + $hasGitState = $false if (-not [string]::IsNullOrWhiteSpace($branchAfterRun) -and $branchAfterRun -ne $currentBranch) { - $hasGitState = $false - - foreach ($stateFile in $stateFiles) { - if (Test-GitStateFile $stateFile) { - $hasGitState = $true - break - } - } + $hasGitState = Test-AnyGitState if (-not $hasGitState) { git -C $repoRoot switch -- $currentBranch | Out-Null + $switchBackExitCode = $LASTEXITCODE + + if ($switchBackExitCode -ne 0) { + Write-Warning "Failed to switch back to the original branch '$currentBranch' during cleanup. You may still be on '$branchAfterRun'." + } else { + $branchAfterRun = $currentBranch + } } } - if ($stashCreated -and -not [string]::IsNullOrWhiteSpace($stashRef)) { - git -C $repoRoot stash apply --index $stashRef | Out-Null + $hasGitState = Test-AnyGitState - if ($LASTEXITCODE -eq 0) { - git -C $repoRoot stash drop $stashRef | Out-Null + if ($stashCreated -and -not [string]::IsNullOrWhiteSpace($stashRef)) { + if ($hasGitState) { + Write-Warning "Skipped stash restoration because the repository is in the middle of a git operation. The stash '$stashRef' was kept." + } elseif ($branchAfterRun -ne $currentBranch) { + Write-Warning "Skipped stash restoration because the repository is not back on '$currentBranch'. The stash '$stashRef' was kept." + } else { + git -C $repoRoot stash apply --index $stashRef | Out-Null if ($LASTEXITCODE -eq 0) { - Write-Host "Restored stashed worktree from: $stashName" + git -C $repoRoot stash drop $stashRef | Out-Null + + if ($LASTEXITCODE -eq 0) { + Write-Host "Restored stashed worktree from: $stashName" + } else { + Write-Warning "The stashed worktree was applied, but dropping '$stashRef' failed." + } } else { - Write-Warning "The stashed worktree was applied, but dropping '$stashRef' failed." + Write-Warning "Failed to restore stashed worktree from '$stashRef'. The stash was kept." } - } else { - Write-Warning "Failed to restore stashed worktree from '$stashRef'. The stash was kept." } } @@ -372,10 +476,12 @@ finally { } } +$pushSummary = if ($NoPush) { 'push skipped' } else { 'push attempted' } + if ($stashCreated) { - Write-Host "Summary: stashed dirty worktree as '$stashName', updated from master using '$Mode', and attempted to restore the stash." + Write-Host "Summary: stashed dirty worktree as '$stashName', updated from '$resolvedBaseBranch' using '$Mode', and $pushSummary." } else { - Write-Host "Summary: no stash-worthy changes outside ignored dot folders were found, updated from master using '$Mode', and no stash was needed." + Write-Host "Summary: no stash-worthy changes outside ignored dot folders were found, updated from '$resolvedBaseBranch' using '$Mode', and $pushSummary." } exit $exitCode From 28e19a0db3c4ffe65205c43988c22e02cea9d2a9 Mon Sep 17 00:00:00 2001 From: Paulo Santos Date: Sun, 17 May 2026 12:18:47 -0300 Subject: [PATCH 5/5] fix(toolkit): address review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ● Clarify log context clearing and database factory error behavior. ● Deduplicate routed loggers and use plain branch switching in the update script. --- .../Unit/Logging/LogContextTests.cs | 16 +++++++ .../Data/Database/DatabaseAccessFactory.cs | 4 ++ Toolkit.Modern/Logging/LogContext.cs | 2 +- .../Logging/Models/LogRoutingContext.cs | 45 ++++++++++++++++++- Update-FromMaster.ps1 | 4 +- 5 files changed, 67 insertions(+), 4 deletions(-) diff --git a/Toolkit.Modern.Tests/Unit/Logging/LogContextTests.cs b/Toolkit.Modern.Tests/Unit/Logging/LogContextTests.cs index 6b60ff6..0c93296 100644 --- a/Toolkit.Modern.Tests/Unit/Logging/LogContextTests.cs +++ b/Toolkit.Modern.Tests/Unit/Logging/LogContextTests.cs @@ -58,6 +58,22 @@ public void BeginRoutingScope_WithNestedScopes_ShouldMergeAndRestoreContext() LogContext.RoutingContext.Should().BeNull(); } + /// + /// Verifies that merged routing contexts do not duplicate the same logger reference. + /// + [TestMethod] + public void Merge_WithDuplicateLoggerReferences_ShouldKeepSingleLogger() + { + var logger = new TestLogger("A"); + var outer = new LogRoutingContext(new[] { logger }, new[] { "File" }); + var inner = new LogRoutingContext(new[] { logger }, new[] { "file" }); + + var result = outer.Merge(inner); + + result.AdditionalLoggers.Should().ContainSingle().Which.Should().BeSameAs(logger); + result.SuppressedLoggerNames.Should().ContainSingle().Which.Should().Be("File"); + } + /// /// Verifies logger suppression is case-insensitive and null-safe. /// diff --git a/Toolkit.Modern/Data/Database/DatabaseAccessFactory.cs b/Toolkit.Modern/Data/Database/DatabaseAccessFactory.cs index a246d80..29f7ba2 100644 --- a/Toolkit.Modern/Data/Database/DatabaseAccessFactory.cs +++ b/Toolkit.Modern/Data/Database/DatabaseAccessFactory.cs @@ -16,6 +16,10 @@ public static IDatabaseAccess Create() { var rootOptions = TryGetRootOptions(); var dbSection = rootOptions?.SelectedDatabase ?? string.Empty; + + if (string.IsNullOrEmpty(dbSection)) + throw new InvalidOperationException("No selected database is configured in the Data Source section."); + return Create(dbSection); } diff --git a/Toolkit.Modern/Logging/LogContext.cs b/Toolkit.Modern/Logging/LogContext.cs index e1c5c21..f752611 100644 --- a/Toolkit.Modern/Logging/LogContext.cs +++ b/Toolkit.Modern/Logging/LogContext.cs @@ -73,7 +73,7 @@ public static IDisposable BeginRoutingScope(IEnumerable additionalLogge } /// - /// Clears the page identifier from the current execution context. + /// Clears the page identifier and routing context from the current execution context. /// public static void Clear() { diff --git a/Toolkit.Modern/Logging/Models/LogRoutingContext.cs b/Toolkit.Modern/Logging/Models/LogRoutingContext.cs index eadc0e4..79abec8 100644 --- a/Toolkit.Modern/Logging/Models/LogRoutingContext.cs +++ b/Toolkit.Modern/Logging/Models/LogRoutingContext.cs @@ -46,8 +46,10 @@ public LogRoutingContext Merge(LogRoutingContext other) if (other == null) return this; + var loggerComparer = ReferenceEqualityComparer.Instance; + return new LogRoutingContext( - AdditionalLoggers.Concat(other.AdditionalLoggers), + AdditionalLoggers.Concat(other.AdditionalLoggers).Distinct(loggerComparer), SuppressedLoggerNames.Concat(other.SuppressedLoggerNames)); } @@ -63,5 +65,46 @@ public bool IsSuppressed(ILogger logger) return SuppressedLoggerNames.Contains(logger.Name); } + + /// + /// Compares reference types by object identity. + /// + /// The reference type to compare. + private sealed class ReferenceEqualityComparer : IEqualityComparer + where T : class + { + /// + /// Gets the shared comparer instance. + /// + public static readonly ReferenceEqualityComparer Instance = new ReferenceEqualityComparer(); + + /// + /// Prevents direct construction of the comparer. + /// + private ReferenceEqualityComparer() + { + } + + /// + /// Determines whether two references point to the same object. + /// + /// The first object reference. + /// The second object reference. + /// when both references point to the same object; otherwise, . + public bool Equals(T? x, T? y) + { + return ReferenceEquals(x, y); + } + + /// + /// Gets a hash code based on object identity. + /// + /// The object reference. + /// The identity-based hash code for the object. + public int GetHashCode(T obj) + { + return System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(obj); + } + } } } diff --git a/Update-FromMaster.ps1 b/Update-FromMaster.ps1 index b95fcce..71319b7 100644 --- a/Update-FromMaster.ps1 +++ b/Update-FromMaster.ps1 @@ -357,7 +357,7 @@ if errorlevel 1 exit /b 1 echo. echo ^> git switch "%BASE_BRANCH%" -git switch -- "%BASE_BRANCH%" +git switch "%BASE_BRANCH%" if errorlevel 1 exit /b 1 echo. @@ -367,7 +367,7 @@ if errorlevel 1 exit /b 1 echo. echo ^> git switch "%ORIGINAL_BRANCH%" -git switch -- "%ORIGINAL_BRANCH%" +git switch "%ORIGINAL_BRANCH%" if errorlevel 1 exit /b 1 echo.