using System.Globalization; using System.Text; using System.Text.Json; namespace FinalFactory.Rendezvous.TestClient; internal sealed class TestClientOutput(TextWriter standardOutput, TextWriter standardError, bool json) { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = false, }; private readonly object _gate = new(); private readonly TextWriter _standardOutput = standardOutput ?? throw new ArgumentNullException(nameof(standardOutput)); private readonly TextWriter _standardError = standardError ?? throw new ArgumentNullException(nameof(standardError)); private readonly bool _json = json; internal void Write( string eventName, string status, string? phase = null, string? listingId = null, string? displayName = null, string? outcome = null, string? endpointType = null, int? count = null, long? elapsedMilliseconds = null, string? message = null) => WriteCore( _standardOutput, new TestClientEvent { Event = SafeToken(eventName) ?? string.Empty, Status = SafeToken(status) ?? string.Empty, Phase = SafeToken(phase), ListingId = SafeToken(listingId), DisplayName = SafeText(displayName), Outcome = SafeToken(outcome), EndpointType = SafeToken(endpointType), Count = count, ElapsedMilliseconds = elapsedMilliseconds, Message = SafeText(message), }); internal void WriteError( string eventName, string status, string message, string? phase = null, string? outcome = null) => WriteCore( _standardError, new TestClientEvent { Event = SafeToken(eventName) ?? string.Empty, Status = SafeToken(status) ?? string.Empty, Phase = SafeToken(phase), Outcome = SafeToken(outcome), Message = SafeText(message), }); internal void WritePrompt(string prompt) { if (_json) { return; } lock (_gate) { _standardOutput.Write(SafeText(prompt)); _standardOutput.Flush(); } } private void WriteCore(TextWriter writer, TestClientEvent item) { string line = _json ? JsonSerializer.Serialize(item, JsonOptions) : HumanLine(item); lock (_gate) { writer.WriteLine(line); writer.Flush(); } } private static string HumanLine(TestClientEvent item) { StringBuilder line = new(); line.Append('[').Append(item.Status).Append("] ").Append(item.Event); Append(line, "phase", item.Phase); Append(line, "listing", item.ListingId); Append(line, "name", item.DisplayName, quote: true); Append(line, "outcome", item.Outcome); Append(line, "endpoint", item.EndpointType); if (item.Count.HasValue) { Append(line, "count", item.Count.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)); } if (item.ElapsedMilliseconds.HasValue) { Append( line, "elapsedMs", item.ElapsedMilliseconds.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)); } Append(line, "message", item.Message, quote: true); return line.ToString(); } private static void Append( StringBuilder builder, string name, string? value, bool quote = false) { if (!string.IsNullOrEmpty(value)) { builder.Append(' ').Append(name).Append('='); if (quote) { builder.Append('"').Append(value.Replace("\\", "\\\\", StringComparison.Ordinal) .Replace("\"", "\\\"", StringComparison.Ordinal)).Append('"'); } else { builder.Append(value); } } } private static string? SafeToken(string? value) { if (value is null) { return null; } return new string(value .Take(96) .Select(static character => char.IsAsciiLetterOrDigit(character) || character is '.' or '-' or '_' or ':' ? char.ToLowerInvariant(character) : '_') .ToArray()); } private static string? SafeText(string? value) { if (value is null) { return null; } return new string(value .Take(160) .Select(static character => IsUnsafeHumanCharacter(character) ? '?' : character) .ToArray()); } private static bool IsUnsafeHumanCharacter(char character) => char.GetUnicodeCategory(character) is UnicodeCategory.Control or UnicodeCategory.Format or UnicodeCategory.LineSeparator or UnicodeCategory.ParagraphSeparator or UnicodeCategory.Surrogate or UnicodeCategory.PrivateUse; private sealed class TestClientEvent { public int Version { get; init; } = 1; public string Event { get; init; } = string.Empty; public string Status { get; init; } = string.Empty; public string? Phase { get; init; } public string? ListingId { get; init; } public string? DisplayName { get; init; } public string? Outcome { get; init; } public string? EndpointType { get; init; } public int? Count { get; init; } public long? ElapsedMilliseconds { get; init; } public string? Message { get; init; } } }