diff --git a/src/libraries/Microsoft.Extensions.Configuration.Ini/ref/Microsoft.Extensions.Configuration.Ini.cs b/src/libraries/Microsoft.Extensions.Configuration.Ini/ref/Microsoft.Extensions.Configuration.Ini.cs index e23fd718f00d7b..323668c957673c 100644 --- a/src/libraries/Microsoft.Extensions.Configuration.Ini/ref/Microsoft.Extensions.Configuration.Ini.cs +++ b/src/libraries/Microsoft.Extensions.Configuration.Ini/ref/Microsoft.Extensions.Configuration.Ini.cs @@ -15,9 +15,25 @@ public static partial class IniConfigurationExtensions public static Microsoft.Extensions.Configuration.IConfigurationBuilder AddIniFile(this Microsoft.Extensions.Configuration.IConfigurationBuilder builder, string path, bool optional, bool reloadOnChange) { throw null; } public static Microsoft.Extensions.Configuration.IConfigurationBuilder AddIniStream(this Microsoft.Extensions.Configuration.IConfigurationBuilder builder, System.IO.Stream stream) { throw null; } } + public static partial class WritableIniConfigurationExtensions + { + public static Microsoft.Extensions.Configuration.IConfigurationBuilder AddWritableIniFile(this Microsoft.Extensions.Configuration.IConfigurationBuilder builder, string path, bool optional = true) { throw null; } + } } namespace Microsoft.Extensions.Configuration.Ini { + public sealed partial class IniDocument + { + public IniDocument() { } + public System.Collections.Generic.IReadOnlyList Sections { get { throw null; } } + public System.Collections.Generic.IReadOnlyList<(string Key, string Value)> Entries(string section) { throw null; } + public string? Get(string section, string key) { throw null; } + public static Microsoft.Extensions.Configuration.Ini.IniDocument Parse(string text) { throw null; } + public bool Remove(string section, string key) { throw null; } + public void Set(string section, string key, string value, bool? quote = default(bool?)) { } + public override string ToString() { throw null; } + public string ToIniString() { throw null; } + } public partial class IniConfigurationProvider : Microsoft.Extensions.Configuration.FileConfigurationProvider { public IniConfigurationProvider(Microsoft.Extensions.Configuration.Ini.IniConfigurationSource source) : base (default(Microsoft.Extensions.Configuration.FileConfigurationSource)) { } @@ -39,4 +55,19 @@ public partial class IniStreamConfigurationSource : Microsoft.Extensions.Configu public IniStreamConfigurationSource() { } public override Microsoft.Extensions.Configuration.IConfigurationProvider Build(Microsoft.Extensions.Configuration.IConfigurationBuilder builder) { throw null; } } + public sealed partial class WritableIniConfigurationProvider : Microsoft.Extensions.Configuration.ConfigurationProvider + { + public WritableIniConfigurationProvider(Microsoft.Extensions.Configuration.Ini.WritableIniConfigurationSource source) { } + public string Path { get { throw null; } } + public override void Load() { } + public void Save() { } + public void SetValue(string section, string key, string value, bool? quote = default(bool?)) { } + } + public sealed partial class WritableIniConfigurationSource : Microsoft.Extensions.Configuration.IConfigurationSource + { + public WritableIniConfigurationSource() { } + public bool Optional { get { throw null; } set { } } + public required string Path { get { throw null; } set { } } + public Microsoft.Extensions.Configuration.IConfigurationProvider Build(Microsoft.Extensions.Configuration.IConfigurationBuilder builder) { throw null; } + } } diff --git a/src/libraries/Microsoft.Extensions.Configuration.Ini/src/IniDocument.cs b/src/libraries/Microsoft.Extensions.Configuration.Ini/src/IniDocument.cs new file mode 100644 index 00000000000000..8cc2b763aa4009 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Configuration.Ini/src/IniDocument.cs @@ -0,0 +1,175 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace Microsoft.Extensions.Configuration.Ini +{ + /// + /// A lossless, editable model of an INI file: a sequence of [Section] + /// blocks whose entries are Key=value lines, where integer-style values + /// are written bare (VICIIModel=3) and string values may be + /// double-quoted (WIC64MACAddress="08:d1:f9:0a:0c:0e"). + /// + /// This is the write engine the built-in + /// lacks. Because callers frequently edit a config file they share + /// with another application, the document preserves every section, key, order, + /// and quoting it parsed: a read-modify-write round-trips losslessly and never + /// drops resources the caller does not itself manage. + /// + /// (Comment lines and blank-line layout are not preserved on write; the read + /// path ignores them, matching the built-in provider.) + /// + public sealed class IniDocument + { + private sealed class IniEntry + { + public required string Key { get; init; } + public string Value { get; set; } = string.Empty; + public bool Quoted { get; set; } + } + + private sealed class IniSection + { + public required string Name { get; init; } + public List Entries { get; } = new(); + } + + private readonly List _sections = new(); + + /// Section names in document order. + public IReadOnlyList Sections => _sections.ConvertAll(s => s.Name); + + /// Parse INI text into an editable, round-trippable document. + public static IniDocument Parse(string text) + { + ArgumentNullException.ThrowIfNull(text); + + var document = new IniDocument(); + IniSection? current = null; + + foreach (var rawLine in text.Split('\n')) + { + var line = rawLine.TrimEnd('\r').Trim(); + if (line.Length == 0) + continue; + + // Skip comment lines (parity with the built-in INI read path). + if (line[0] is ';' or '#' or '/') + continue; + + if (line[0] == '[' && line[^1] == ']') + { + var name = line[1..^1].Trim(); + current = document.GetOrAddSection(name); + continue; + } + + var eq = line.IndexOf('='); + if (eq <= 0 || current is null) + continue; + + var key = line[..eq].Trim(); + var rawValue = line[(eq + 1)..].Trim(); + var quoted = rawValue.Length >= 2 && rawValue[0] == '"' && rawValue[^1] == '"'; + var value = quoted ? rawValue[1..^1] : rawValue; + + current.Entries.Add(new IniEntry { Key = key, Value = value, Quoted = quoted }); + } + + return document; + } + + /// Get a value (quotes stripped), or null if the section/key is absent. + public string? Get(string section, string key) + { + var entry = FindSection(section)?.Entries + .FirstOrDefault(e => string.Equals(e.Key, key, StringComparison.Ordinal)); + return entry?.Value; + } + + /// + /// Set a value, updating an existing entry in place or appending a new one + /// (creating the section if needed). : true forces + /// double-quoting, false forces bare; null (the default) preserves the + /// existing entry's quoting on update and writes bare for a new entry. + /// Preserving on update is what lets a read-modify-write round-trip a shared + /// file without unquoting its string values. + /// + public void Set(string section, string key, string value, bool? quote = null) + { + ArgumentNullException.ThrowIfNull(value); + + var target = GetOrAddSection(section); + var entry = target.Entries + .FirstOrDefault(e => string.Equals(e.Key, key, StringComparison.Ordinal)); + if (entry is null) + { + target.Entries.Add(new IniEntry { Key = key, Value = value, Quoted = quote ?? false }); + return; + } + + entry.Value = value; + if (quote.HasValue) + entry.Quoted = quote.Value; + } + + /// Remove a value. Returns true if it existed. + public bool Remove(string section, string key) + { + var target = FindSection(section); + return target is not null + && target.Entries.RemoveAll(e => string.Equals(e.Key, key, StringComparison.Ordinal)) > 0; + } + + /// Entries of a section in order, with quotes stripped. + public IReadOnlyList<(string Key, string Value)> Entries(string section) + { + var target = FindSection(section); + return target is null ? Array.Empty<(string, string)>() : target.Entries.ConvertAll(e => (e.Key, e.Value)); + } + + /// Serialize back to INI text (sections in order, blank line between each). + public string ToIniString() + { + var builder = new StringBuilder(); + foreach (var section in _sections) + { + builder.Append('[').Append(section.Name).Append(']').Append('\n'); + foreach (var entry in section.Entries) + { + builder.Append(entry.Key).Append('='); + if (entry.Quoted) + builder.Append('"').Append(entry.Value).Append('"'); + else + builder.Append(entry.Value); + builder.Append('\n'); + } + + builder.Append('\n'); + } + + return builder.ToString(); + } + + /// Returns this document serialized as INI text. + public override string ToString() => ToIniString(); + + private IniSection? FindSection(string section) => + _sections.FirstOrDefault(s => string.Equals(s.Name, section, StringComparison.Ordinal)); + + private IniSection GetOrAddSection(string section) + { + var existing = FindSection(section); + if (existing is not null) + return existing; + + var created = new IniSection { Name = section }; + _sections.Add(created); + return created; + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Configuration.Ini/src/WritableIniConfigurationExtensions.cs b/src/libraries/Microsoft.Extensions.Configuration.Ini/src/WritableIniConfigurationExtensions.cs new file mode 100644 index 00000000000000..8c951887aab055 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Configuration.Ini/src/WritableIniConfigurationExtensions.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.Extensions.Configuration.Ini; + +namespace Microsoft.Extensions.Configuration +{ + /// + /// Extension methods for adding a writable INI configuration source. + /// + public static class WritableIniConfigurationExtensions + { + /// + /// Adds a writable INI file at to + /// . The source resolves to a + /// whose + /// writes changes + /// back losslessly. + /// + /// The builder to add to. + /// The physical path of the INI file. + /// + /// When true (default) a missing file yields empty configuration; when false + /// loading a missing file throws. + /// + /// The . + public static IConfigurationBuilder AddWritableIniFile( + this IConfigurationBuilder builder, string path, bool optional = true) + { + ArgumentNullException.ThrowIfNull(builder); + if (string.IsNullOrEmpty(path)) + throw new ArgumentException(SR.Error_InvalidFilePath, nameof(path)); + + return builder.Add(new Ini.WritableIniConfigurationSource { Path = path, Optional = optional }); + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Configuration.Ini/src/WritableIniConfigurationProvider.cs b/src/libraries/Microsoft.Extensions.Configuration.Ini/src/WritableIniConfigurationProvider.cs new file mode 100644 index 00000000000000..e2a7f638c5e07d --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Configuration.Ini/src/WritableIniConfigurationProvider.cs @@ -0,0 +1,115 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; + +namespace Microsoft.Extensions.Configuration.Ini +{ + /// + /// The writable provider behind . + /// parses the INI into configuration data (identical keys to + /// the built-in provider); writes the current data back via + /// , so unmanaged sections/keys and value quoting are + /// preserved across a read-modify-write. + /// + public sealed class WritableIniConfigurationProvider : ConfigurationProvider + { + private readonly WritableIniConfigurationSource _source; + private readonly Dictionary _quoting = new(StringComparer.OrdinalIgnoreCase); + + /// Initializes a new instance for the given source. + public WritableIniConfigurationProvider(WritableIniConfigurationSource source) + { + ArgumentNullException.ThrowIfNull(source); + _source = source; + } + + /// The INI file this provider reads from and writes to. + public string Path => _source.Path; + + /// + /// Loads the INI file into configuration data. A missing file yields empty + /// data when the source is optional, otherwise throws. + /// + public override void Load() + { + var data = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (File.Exists(_source.Path)) + { + var document = IniDocument.Parse(File.ReadAllText(_source.Path)); + foreach (var section in document.Sections) + { + foreach (var (key, value) in document.Entries(section)) + data[ToConfigKey(section, key)] = value; + } + } + else if (!_source.Optional) + { + throw new FileNotFoundException($"Required INI file not found: {_source.Path}", _source.Path); + } + + Data = data; + } + + /// + /// Sets a resource. controls how + /// writes it: true = double-quoted, false = bare, null = preserve the + /// existing entry's quoting (or bare if new). + /// + public void SetValue(string section, string key, string value, bool? quote = null) + { + var configKey = ToConfigKey(section, key); + Set(configKey, value); + if (quote.HasValue) + _quoting[configKey] = quote.Value; + } + + /// + /// Writes the current configuration back to the INI file via a + /// read-modify-write: existing resources keep their quoting and any + /// resources not present in the data are preserved verbatim. + /// + public void Save() + { + var document = File.Exists(_source.Path) + ? IniDocument.Parse(File.ReadAllText(_source.Path)) + : new IniDocument(); + + foreach (var (configKey, value) in Data) + { + if (value is null || !TrySplitConfigKey(configKey, out var section, out var resource)) + continue; + + var quote = _quoting.TryGetValue(configKey, out var q) ? q : (bool?)null; + document.Set(section, resource, value, quote); + } + + var directory = System.IO.Path.GetDirectoryName(_source.Path); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + + File.WriteAllText(_source.Path, document.ToIniString()); + } + + private static string ToConfigKey(string section, string key) => + $"{section}{ConfigurationPath.KeyDelimiter}{key}"; + + private static bool TrySplitConfigKey(string configKey, out string section, out string resource) + { + var index = configKey.IndexOf(ConfigurationPath.KeyDelimiter, StringComparison.Ordinal); + if (index <= 0 || index >= configKey.Length - 1) + { + section = string.Empty; + resource = string.Empty; + return false; + } + + section = configKey[..index]; + resource = configKey[(index + 1)..]; + return true; + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Configuration.Ini/src/WritableIniConfigurationSource.cs b/src/libraries/Microsoft.Extensions.Configuration.Ini/src/WritableIniConfigurationSource.cs new file mode 100644 index 00000000000000..80b05c2474733e --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Configuration.Ini/src/WritableIniConfigurationSource.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO; + +namespace Microsoft.Extensions.Configuration.Ini +{ + /// + /// An backed by a writable INI file at a + /// physical path. Values are exposed as Section:Key configuration keys + /// (e.g. C64SC:VICIIModel), matching the built-in INI provider, and the + /// matching can write changes + /// back losslessly via . + /// + public sealed class WritableIniConfigurationSource : IConfigurationSource + { + /// The physical path of the INI file to read from and write to. + public required string Path { get; set; } + + /// + /// When true (default) a missing file yields empty configuration instead of + /// throwing on . + /// + public bool Optional { get; set; } = true; + + /// Builds the provider for this source. + public IConfigurationProvider Build(IConfigurationBuilder builder) + => new WritableIniConfigurationProvider(this); + } +} diff --git a/src/libraries/Microsoft.Extensions.Configuration.Ini/tests/IniDocumentTests.cs b/src/libraries/Microsoft.Extensions.Configuration.Ini/tests/IniDocumentTests.cs new file mode 100644 index 00000000000000..98821c2e7d2a70 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Configuration.Ini/tests/IniDocumentTests.cs @@ -0,0 +1,114 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace SharpNinja.Extensions.Configuration.Ini.Tests; + +using global::Microsoft.Extensions.Configuration.Ini; +using Xunit; + +/// +/// IniDocument is the lossless reader/writer beneath the writable provider. +/// These mirror vice-sharp's ViceIniDocument tests (the model ported here): +/// unknown sections/resources and value quoting must survive a read-modify-write +/// so a shared INI (e.g. Classic VICE vice.ini) is never corrupted. +/// +public sealed class IniDocumentTests +{ + private const string Sample = + "[Version]\n" + + "ConfigVersion=3.8\n" + + "\n" + + "[C64SC]\n" + + "SaveResourcesOnExit=1\n" + + "VICIIModel=3\n" + + "SidModel=0\n" + + "WIC64MACAddress=\"08:d1:f9:0a:0c:0e\"\n" + + "Drive8Type=1541\n" + + "\n"; + + [Fact] + public void Get_ReadsIntAndUnquotesString() + { + var doc = IniDocument.Parse(Sample); + + Assert.Equal("3.8", doc.Get("Version", "ConfigVersion")); + Assert.Equal("3", doc.Get("C64SC", "VICIIModel")); + Assert.Equal("08:d1:f9:0a:0c:0e", doc.Get("C64SC", "WIC64MACAddress")); + Assert.Null(doc.Get("C64SC", "NoSuchResource")); + Assert.Null(doc.Get("NoSuchSection", "VICIIModel")); + } + + [Fact] + public void Parse_ThenSerialize_RoundTripsLosslessly() + { + var doc = IniDocument.Parse(Sample); + Assert.Equal(Sample, doc.ToIniString()); + } + + [Fact] + public void Set_UpdatesExistingValue_AndPreservesEverythingElse() + { + var doc = IniDocument.Parse(Sample); + + doc.Set("C64SC", "VICIIModel", "1"); + + Assert.Equal("1", doc.Get("C64SC", "VICIIModel")); + Assert.Equal("1541", doc.Get("C64SC", "Drive8Type")); + Assert.Equal("08:d1:f9:0a:0c:0e", doc.Get("C64SC", "WIC64MACAddress")); + Assert.Equal("3.8", doc.Get("Version", "ConfigVersion")); + } + + [Fact] + public void Set_UpdatingQuotedString_PreservesQuotesByDefault() + { + var doc = IniDocument.Parse(Sample); + + doc.Set("C64SC", "WIC64MACAddress", "aa:bb:cc:dd:ee:ff"); + + Assert.Contains("WIC64MACAddress=\"aa:bb:cc:dd:ee:ff\"", doc.ToIniString()); + } + + [Fact] + public void Set_AddsNewKeyToExistingSection() + { + var doc = IniDocument.Parse(Sample); + + doc.Set("C64SC", "VICIIFilter", "2"); + + Assert.Equal("2", doc.Get("C64SC", "VICIIFilter")); + Assert.Contains("VICIIFilter=2", doc.ToIniString()); + } + + [Fact] + public void Set_AddsNewSectionWhenMissing() + { + var doc = IniDocument.Parse(Sample); + + doc.Set("C128", "VDCRevision", "1"); + + Assert.Equal("1", doc.Get("C128", "VDCRevision")); + Assert.Contains("[C128]", doc.ToIniString()); + } + + [Fact] + public void SetString_QuotesValue_AndGetUnquotes() + { + var doc = IniDocument.Parse(Sample); + + doc.Set("C64SC", "WIC64IPAddress", "192.168.41.165", quote: true); + + Assert.Contains("WIC64IPAddress=\"192.168.41.165\"", doc.ToIniString()); + Assert.Equal("192.168.41.165", doc.Get("C64SC", "WIC64IPAddress")); + } + + [Fact] + public void Remove_DropsKey_AndReportsResult() + { + var doc = IniDocument.Parse(Sample); + + Assert.True(doc.Remove("C64SC", "SidModel")); + Assert.False(doc.Remove("C64SC", "SidModel")); + Assert.Null(doc.Get("C64SC", "SidModel")); + Assert.DoesNotContain("SidModel", doc.ToIniString()); + } +} diff --git a/src/libraries/Microsoft.Extensions.Configuration.Ini/tests/IniReadParityTests.cs b/src/libraries/Microsoft.Extensions.Configuration.Ini/tests/IniReadParityTests.cs new file mode 100644 index 00000000000000..be7d48cdd2a85d --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Configuration.Ini/tests/IniReadParityTests.cs @@ -0,0 +1,69 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace SharpNinja.Extensions.Configuration.Ini.Tests; + +using System; +using System.IO; +using System.Linq; +using global::Microsoft.Extensions.Configuration; +using Xunit; + +/// +/// The writable provider's read path must be byte-for-byte equivalent to the +/// built-in INI provider. The built-in AddIniFile here is the verbatim +/// Microsoft source carried by this package, so parity with it is parity with +/// Microsoft.Extensions.Configuration.Ini. +/// +public sealed class IniReadParityTests +{ + private const string Sample = + "[Version]\n" + + "ConfigVersion=3.8\n" + + "\n" + + "[C64SC]\n" + + "SaveResourcesOnExit=1\n" + + "VICIIModel=3\n" + + "WIC64MACAddress=\"08:d1:f9:0a:0c:0e\"\n" + + "; a comment line\n" + + "Drive8Type=1541\n"; + + private static string WriteTemp(string content) + { + var path = Path.Combine(Path.GetTempPath(), $"sncfg-{Guid.NewGuid():N}.ini"); + File.WriteAllText(path, content); + return path; + } + + [Fact] + public void WritableProvider_ReadsSameKeysAsBuiltInIniProvider() + { + var path = WriteTemp(Sample); + try + { + var builtin = new ConfigurationBuilder().AddIniFile(path, optional: false).Build(); + var writable = new ConfigurationBuilder().AddWritableIniFile(path, optional: false).Build(); + + var expected = builtin.AsEnumerable() + .Where(k => k.Value != null) + .ToDictionary(k => k.Key, k => k.Value, StringComparer.OrdinalIgnoreCase); + var actual = writable.AsEnumerable() + .Where(k => k.Value != null) + .ToDictionary(k => k.Key, k => k.Value, StringComparer.OrdinalIgnoreCase); + + Assert.Equal(expected, actual); + Assert.Equal("3", actual["C64SC:VICIIModel"]); + Assert.Equal("08:d1:f9:0a:0c:0e", actual["C64SC:WIC64MACAddress"]); // quotes stripped + Assert.DoesNotContain(actual.Keys, k => k.Contains("comment", StringComparison.OrdinalIgnoreCase)); + } + finally { File.Delete(path); } + } + + [Fact] + public void WritableProvider_OptionalMissingFile_YieldsEmpty() + { + var missing = Path.Combine(Path.GetTempPath(), $"sncfg-missing-{Guid.NewGuid():N}.ini"); + var config = new ConfigurationBuilder().AddWritableIniFile(missing, optional: true).Build(); + Assert.DoesNotContain(config.AsEnumerable(), k => k.Value != null); + } +} diff --git a/src/libraries/Microsoft.Extensions.Configuration.Ini/tests/WritableIniProviderTests.cs b/src/libraries/Microsoft.Extensions.Configuration.Ini/tests/WritableIniProviderTests.cs new file mode 100644 index 00000000000000..777261841764c8 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Configuration.Ini/tests/WritableIniProviderTests.cs @@ -0,0 +1,111 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace SharpNinja.Extensions.Configuration.Ini.Tests; + +using System; +using System.IO; +using global::Microsoft.Extensions.Configuration; +using global::Microsoft.Extensions.Configuration.Ini; +using Xunit; + +/// +/// Provider-level write behavior: SetValue + Save round-trips through the file, +/// preserving resources the caller never touched and their quoting, and the +/// IConfiguration indexer path (Set) is reconciled into the document on Save. +/// +public sealed class WritableIniProviderTests +{ + private const string Sample = + "[Version]\n" + + "ConfigVersion=3.8\n" + + "\n" + + "[C64SC]\n" + + "VICIIModel=3\n" + + "WIC64MACAddress=\"08:d1:f9:0a:0c:0e\"\n" + + "Drive8Type=1541\n" + + "\n"; + + private static string WriteTemp(string content) + { + var path = Path.Combine(Path.GetTempPath(), $"sncfg-{Guid.NewGuid():N}.ini"); + File.WriteAllText(path, content); + return path; + } + + private static WritableIniConfigurationProvider Provider(string path) + => (WritableIniConfigurationProvider)new WritableIniConfigurationSource { Path = path } + .Build(new ConfigurationBuilder()); + + [Fact] + public void SetValue_Save_PersistsAndPreservesUnknownAndQuoting() + { + var path = WriteTemp(Sample); + try + { + var provider = Provider(path); + provider.Load(); + provider.SetValue("C64SC", "VICIIModel", "1"); + provider.SetValue("C64SC", "WIC64IPAddress", "192.168.41.165", quote: true); + provider.Save(); + + var text = File.ReadAllText(path); + Assert.Contains("VICIIModel=1", text); + Assert.Contains("WIC64IPAddress=\"192.168.41.165\"", text); + Assert.Contains("Drive8Type=1541", text); // unmanaged key survives + Assert.Contains("WIC64MACAddress=\"08:d1:f9:0a:0c:0e\"", text); // quoting survives + + var reloaded = new ConfigurationBuilder().AddWritableIniFile(path).Build(); + Assert.Equal("1", reloaded["C64SC:VICIIModel"]); + Assert.Equal("192.168.41.165", reloaded["C64SC:WIC64IPAddress"]); + } + finally { File.Delete(path); } + } + + [Fact] + public void Save_NewFile_CreatesDirectoryAndWrites() + { + var dir = Path.Combine(Path.GetTempPath(), $"sncfg-{Guid.NewGuid():N}"); + var path = Path.Combine(dir, "settings.ini"); + try + { + var provider = Provider(path); + provider.Load(); + provider.SetValue("C128", "VDCRevision", "1"); + provider.Save(); + + Assert.True(File.Exists(path)); + Assert.Contains("[C128]", File.ReadAllText(path)); + } + finally { if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); } + } + + [Fact] + public void Set_ViaConfigurationIndexer_ThenSave_RoundTrips() + { + var path = WriteTemp(Sample); + try + { + var provider = Provider(path); + provider.Load(); + provider.Set("C64SC:VICIIModel", "2"); // the IConfiguration write path + provider.Save(); + + Assert.Contains("VICIIModel=2", File.ReadAllText(path)); + } + finally { File.Delete(path); } + } + + [Fact] + public void Load_MissingRequiredFile_Throws() + { + var missing = Path.Combine(Path.GetTempPath(), $"sncfg-req-{Guid.NewGuid():N}.ini"); + var provider = (WritableIniConfigurationProvider)new WritableIniConfigurationSource + { + Path = missing, + Optional = false, + }.Build(new ConfigurationBuilder()); + + Assert.Throws(() => provider.Load()); + } +}