Files
Encelado/Encelado/src/Encelado.Bot/Configuration/ConfigWriter.cs
T

199 lines
6.4 KiB
C#

using System.Text.Json;
using System.Text.Json.Nodes;
namespace Encelado.Bot.Configuration;
/// <summary>
/// Targeted edits to <c>encelado.json</c> made from the settings screen.
/// <para>
/// The file is parsed into a <see cref="JsonNode"/> tree, one value is replaced, and
/// the tree is written back. Serialising a <see cref="BotConfig"/> instead would be
/// simpler and wrong: it would silently delete every key the loader does not model —
/// including the <c>_</c>-prefixed lines that document what each number is for and why
/// it has that value — and reorder everything else.
/// </para>
/// <para>
/// The write goes to a temporary file first and is then moved into place, so a failure
/// halfway through leaves the previous configuration intact rather than a truncated
/// file the application cannot start from.
/// </para>
/// </summary>
public static class ConfigWriter
{
private static readonly JsonWriterOptions WriteOptions = new() { Indented = true };
private static readonly JsonDocumentOptions ReadOptions = new()
{
CommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
/// <summary>Sets <c>logging.directory</c> and saves.</summary>
public static void SetLogDirectory(string configPath, string directory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(configPath);
ArgumentException.ThrowIfNullOrWhiteSpace(directory);
Apply(configPath, new Dictionary<string, JsonNode?> { ["logging.directory"] = directory });
}
/// <summary>
/// Writes a batch of values addressed by dotted path, in one atomic save.
/// <para>
/// Paths look like <c>risk.stakePct</c>, <c>engine.timeFrame</c> or
/// <c>symbols[0].parameters.period</c>. Missing intermediate objects are created;
/// missing array elements are an error, because inventing a symbol out of a typo
/// would be worse than refusing.
/// </para>
/// <para>
/// A batch is all-or-nothing on purpose. Applying half a settings screen would leave
/// a configuration that no one chose — for instance a stake raised without the
/// position cap that has to accompany it, which the validator would then reject at
/// the next start.
/// </para>
/// </summary>
public static void Apply(string configPath, IReadOnlyDictionary<string, JsonNode?> changes)
{
ArgumentException.ThrowIfNullOrWhiteSpace(configPath);
ArgumentNullException.ThrowIfNull(changes);
if (changes.Count == 0)
{
return;
}
Update(configPath, root =>
{
foreach ((string path, JsonNode? value) in changes)
{
SetPath(root, path, value);
}
});
}
private static void SetPath(JsonObject root, string path, JsonNode? value)
{
string[] segments = path.Split('.', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length == 0)
{
throw new ArgumentException($"Percorso vuoto.", nameof(path));
}
JsonNode current = root;
for (int i = 0; i < segments.Length - 1; i++)
{
current = Descend(current, segments[i], path);
}
(string name, int? index) = Parse(segments[^1]);
if (index is { } arrayIndex)
{
JsonArray array = Array(current, name, path);
if (arrayIndex >= array.Count)
{
throw new InvalidOperationException(
$"'{path}': l'elemento {arrayIndex} non esiste in '{name}'.");
}
array[arrayIndex] = value;
return;
}
if (current is not JsonObject target)
{
throw new InvalidOperationException($"'{path}': '{name}' non è dentro un oggetto.");
}
target[name] = value;
}
private static JsonNode Descend(JsonNode current, string segment, string path)
{
(string name, int? index) = Parse(segment);
if (index is { } arrayIndex)
{
JsonArray array = Array(current, name, path);
if (arrayIndex >= array.Count)
{
throw new InvalidOperationException(
$"'{path}': l'elemento {arrayIndex} non esiste in '{name}'.");
}
return array[arrayIndex]
?? throw new InvalidOperationException($"'{path}': '{name}[{arrayIndex}]' è null.");
}
if (current is not JsonObject parent)
{
throw new InvalidOperationException($"'{path}': '{name}' non è dentro un oggetto.");
}
if (parent[name] is not JsonObject child)
{
child = [];
parent[name] = child;
}
return child;
}
private static JsonArray Array(JsonNode current, string name, string path)
{
if (current is not JsonObject parent)
{
throw new InvalidOperationException($"'{path}': '{name}' non è dentro un oggetto.");
}
return parent[name] as JsonArray
?? throw new InvalidOperationException($"'{path}': '{name}' non è un array.");
}
/// <summary>Splits <c>symbols[0]</c> into its name and index.</summary>
private static (string Name, int? Index) Parse(string segment)
{
int bracket = segment.IndexOf('[', StringComparison.Ordinal);
if (bracket < 0)
{
return (segment, null);
}
if (!segment.EndsWith(']') ||
!int.TryParse(segment.AsSpan(bracket + 1, segment.Length - bracket - 2), out int index) ||
index < 0)
{
throw new ArgumentException($"Indice non valido in '{segment}'.");
}
return (segment[..bracket], index);
}
private static void Update(string path, Action<JsonObject> edit)
{
if (!File.Exists(path))
{
throw new FileNotFoundException($"Configurazione non trovata: {path}", path);
}
JsonNode? parsed = JsonNode.Parse(File.ReadAllText(path), documentOptions: ReadOptions);
if (parsed is not JsonObject root)
{
throw new InvalidOperationException($"{path} non contiene un oggetto JSON.");
}
edit(root);
string temporary = path + ".tmp";
using (FileStream stream = File.Create(temporary))
using (Utf8JsonWriter writer = new(stream, WriteOptions))
{
root.WriteTo(writer);
}
File.Move(temporary, path, overwrite: true);
}
}