Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ The settings window (`SemiPlot.UI/Settings`) is the viewer's only writer of the
edits only keys that already exist, each in the file that owns it, and only the keys the operator
changed; it creates no file, and the startup-failure window offers it only when both sections read and carry every key it edits. The
production loaders validate a staged copy before any file is replaced, and every change takes effect at the
next start (`docs/architecture/overview.md#the-settings-window`).
next start except the theme, which every running process applies live
(`docs/architecture/overview.md#the-live-theme`).

`.editorconfig`'s style and quality analyzer rules fail `dotnet build` (`TreatWarningsAsErrors`,
`EnforceCodeStyleInBuild`) and `dotnet format SemiPlot.slnx --verify-no-changes` alike, so a
Expand Down
16 changes: 15 additions & 1 deletion SemiPlot/SemiPlot.Core/Configuration/ConfigurationSection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ private static Dictionary<string, string> ScalarsOf(Dictionary<string, object?>
{
try
{
var content = File.ReadAllText(file);
var content = ReadShared(file);
var repeated = FindRepeatedKey(content);

if (repeated is not null)
Expand All @@ -146,6 +146,20 @@ private static Dictionary<string, string> ScalarsOf(Dictionary<string, object?>
}
}

private static string ReadShared(string file)
{
using var stream = OpenShared(file);
using var reader = new StreamReader(stream);

return reader.ReadToEnd();
}

/// <summary>Opens a section file for reading without blocking another process's File.Replace of it.</summary>
internal static FileStream OpenShared(string file)
{
return new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
}

// WithDuplicateKeyChecking refuses the file but names the key only inside its own message, and the
// operator needs the key, so the top-level mapping is walked for it first.
private static string? FindRepeatedKey(string content)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ private Result Copy(string file, string name)

try
{
content = File.ReadAllBytes(file);
content = ReadAllBytesShared(file);
}
catch (Exception exception)
{
Expand All @@ -184,6 +184,15 @@ private Result Copy(string file, string name)
}
}

private static byte[] ReadAllBytesShared(string file)
{
using var stream = ConfigurationSection.OpenShared(file);
using var content = new MemoryStream();
stream.CopyTo(content);

return content.ToArray();
}

private Result Write(string name, string content)
{
try
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,37 @@ public void AnEmptyFileContributesNothingAndFailsNothing()
.Be(new KeyValuePair<string, string>("locale", "ru"));
}

[Fact]
public void AFileAnotherHandleHoldsOpenForWritingStillReads()
{
WriteFile("a.yaml", "locale: ru\n");
using var writer = new FileStream(
Path.Combine(_directory, "a.yaml"), FileMode.Open, FileAccess.ReadWrite, FileShare.Read);

var result = ConfigurationSection.Read(_directory, ConfigurationSectionName.App);

result.IsSuccess.Should().BeTrue(Describe(result));
MappingOf(result.Value).Should().Contain("locale", "ru");
}

[Fact]
public void AFileTheSectionReadHoldsOpenCanStillBeReplaced()
{
WriteFile("a.yaml", "locale: ru\n");
var staged = Path.Combine(_directory, "a.yaml.staged");
File.WriteAllText(staged, "locale: en\n");

using (ConfigurationSection.OpenShared(Path.Combine(_directory, "a.yaml")))
{
File.Replace(staged, Path.Combine(_directory, "a.yaml"), destinationBackupFileName: null);
}

var result = ConfigurationSection.Read(_directory, ConfigurationSectionName.App);

result.IsSuccess.Should().BeTrue(Describe(result));
MappingOf(result.Value).Should().Contain("locale", "en");
}

[Fact]
public void AFileWrittenWithAByteOrderMarkStillContributesItsFirstKey()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,24 @@ public void ASectionFolderRemovedAfterTheReadFailsWithUnlistable()
error.Directory.Should().Be(_section);
}

[Fact]
public void AFileAnotherSavesWriteProbeHoldsOpenIsStillCopied()
{
WriteFile("a.yaml", "locale: ru\n");
WriteFile("b.yaml", "theme: light\n");
var owned = OwnedOf(ConfigurationSectionName.App);
var held = Path.Combine(_section, "b.yaml");
Result<IReadOnlyList<string>> staged;

using (new FileStream(held, FileMode.Open, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete))
{
staged = Stage(owned, ConfigurationSectionName.App, ("locale", "en"));
}

staged.IsSuccess.Should().BeTrue(Describe(staged));
File.ReadAllBytes(Path.Combine(_staging, "b.yaml")).Should().Equal(File.ReadAllBytes(held));
}

[Fact]
public void ACopyThatCannotBeWrittenFailsWithUnwritableNamingTheFile()
{
Expand Down
Loading
Loading