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.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/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.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..0c93296
--- /dev/null
+++ b/Toolkit.Modern.Tests/Unit/Logging/LogContextTests.cs
@@ -0,0 +1,122 @@
+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 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.
+ ///
+ [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");
+ }
+ }
+}
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/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);
}
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