199 lines
6.4 KiB
C#
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);
|
|
}
|
|
}
|