From d40ee85e14ceaad7de0cf7e6f19ced5bebacc290 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 20 May 2026 22:00:57 -0400 Subject: [PATCH] feat(cli): table output formatter for audit events (#23 M8) --- src/ScadaLink.CLI/Commands/AuditFormatter.cs | 11 +- .../Commands/TableAuditFormatter.cs | 96 ++++++++++++++ .../Commands/AuditTableFormatterTests.cs | 117 ++++++++++++++++++ 3 files changed, 217 insertions(+), 7 deletions(-) create mode 100644 src/ScadaLink.CLI/Commands/TableAuditFormatter.cs create mode 100644 tests/ScadaLink.CLI.Tests/Commands/AuditTableFormatterTests.cs diff --git a/src/ScadaLink.CLI/Commands/AuditFormatter.cs b/src/ScadaLink.CLI/Commands/AuditFormatter.cs index ba0d959..ebfb600 100644 --- a/src/ScadaLink.CLI/Commands/AuditFormatter.cs +++ b/src/ScadaLink.CLI/Commands/AuditFormatter.cs @@ -29,19 +29,16 @@ public sealed class JsonLinesAuditFormatter : IAuditFormatter } /// -/// Resolves an for a given --format value. The -/// table formatter is filled in by Bundle C; until then --format table falls back -/// to JSONL with a one-time notice so the flag is wired but not silently broken. +/// Resolves an for a given --format value: +/// table renders a column-aligned text table (), +/// any other value (including json) renders JSONL. /// public static class AuditFormatterFactory { public static IAuditFormatter Create(string format, TextWriter notices) { if (string.Equals(format, "table", StringComparison.OrdinalIgnoreCase)) - { - notices.WriteLine("note: 'table' output is not yet available; using json. (Bundle C)"); - return new JsonLinesAuditFormatter(); - } + return new TableAuditFormatter(); return new JsonLinesAuditFormatter(); } diff --git a/src/ScadaLink.CLI/Commands/TableAuditFormatter.cs b/src/ScadaLink.CLI/Commands/TableAuditFormatter.cs new file mode 100644 index 0000000..98c9b6c --- /dev/null +++ b/src/ScadaLink.CLI/Commands/TableAuditFormatter.cs @@ -0,0 +1,96 @@ +using System.Text.Json; + +namespace ScadaLink.CLI.Commands; + +/// +/// Human-readable table formatter for audit query --format table (Audit Log +/// #23 M8-T6). Renders each fetched page as a column-aligned text table with a fixed +/// column set (). Long free-text fields (Target, Actor) are +/// truncated with an ellipsis so columns stay aligned regardless of payload size. +/// +/// +/// A header row is emitted once per page (matching the streamable, page-at-a-time +/// contract of ). An empty page emits the header only, +/// so the column shape is visible even with zero results. +/// +public sealed class TableAuditFormatter : IAuditFormatter +{ + /// JSON property name (camelCase, as the server serializes it) → column header. + private static readonly (string Property, string Header, int MaxWidth)[] Columns = + { + ("occurredAtUtc", "OccurredAtUtc", 24), + ("channel", "Channel", 14), + ("kind", "Kind", 18), + ("status", "Status", 12), + ("target", "Target", 32), + ("actor", "Actor", 20), + ("durationMs", "DurationMs", 10), + ("httpStatus", "HttpStatus", 10), + }; + + public void WritePage(IReadOnlyList events, TextWriter output) + { + // Build every cell first so column widths account for the actual data. + var rows = new List(events.Count); + foreach (var evt in events) + { + var cells = new string[Columns.Length]; + for (var i = 0; i < Columns.Length; i++) + cells[i] = Truncate(CellValue(evt, Columns[i].Property), Columns[i].MaxWidth); + rows.Add(cells); + } + + var widths = new int[Columns.Length]; + for (var i = 0; i < Columns.Length; i++) + widths[i] = Columns[i].Header.Length; + foreach (var row in rows) + for (var i = 0; i < Columns.Length; i++) + widths[i] = Math.Max(widths[i], row[i].Length); + + WriteRow(output, Columns.Select(c => c.Header).ToArray(), widths); + foreach (var row in rows) + WriteRow(output, row, widths); + } + + /// + /// Extracts a cell value for from an audit event. + /// A missing property or a JSON null renders as an empty string (never + /// the literal text "null"). + /// + private static string CellValue(JsonElement evt, string property) + { + if (evt.ValueKind != JsonValueKind.Object + || !evt.TryGetProperty(property, out var value) + || value.ValueKind == JsonValueKind.Null) + { + return string.Empty; + } + + return value.ValueKind == JsonValueKind.String + ? value.GetString() ?? string.Empty + : value.ToString(); + } + + /// + /// Truncates to characters, + /// replacing the tail with a single-character ellipsis so the column stays aligned. + /// + private static string Truncate(string value, int maxWidth) + { + if (maxWidth <= 0 || value.Length <= maxWidth) + return value; + if (maxWidth == 1) + return "…"; + return value.Substring(0, maxWidth - 1) + "…"; + } + + private static void WriteRow(TextWriter output, IReadOnlyList cells, int[] widths) + { + for (var i = 0; i < cells.Count; i++) + { + // Last column is not padded — avoids trailing whitespace at line end. + output.Write(i == cells.Count - 1 ? cells[i] : cells[i].PadRight(widths[i] + 2)); + } + output.WriteLine(); + } +} diff --git a/tests/ScadaLink.CLI.Tests/Commands/AuditTableFormatterTests.cs b/tests/ScadaLink.CLI.Tests/Commands/AuditTableFormatterTests.cs new file mode 100644 index 0000000..c22da8b --- /dev/null +++ b/tests/ScadaLink.CLI.Tests/Commands/AuditTableFormatterTests.cs @@ -0,0 +1,117 @@ +using System.Text.Json; +using ScadaLink.CLI.Commands; + +namespace ScadaLink.CLI.Tests.Commands; + +/// +/// Tests for the table output formatter of the scadalink audit query +/// subcommand (Audit Log #23 M8-T6): header rendering, long-field truncation, the +/// empty-result-set case, and null-actor handling. +/// +public class AuditTableFormatterTests +{ + private static IReadOnlyList Events(string json) + { + using var doc = JsonDocument.Parse(json); + return doc.RootElement.EnumerateArray() + .Select(e => e.Clone()) + .ToList(); + } + + [Fact] + public void Table_RendersHeaderRow_WithExpectedColumns() + { + var formatter = new TableAuditFormatter(); + var output = new StringWriter(); + + formatter.WritePage(Events("[]"), output); + + var firstLine = output.ToString() + .Split('\n', StringSplitOptions.RemoveEmptyEntries)[0]; + foreach (var col in new[] + { + "OccurredAtUtc", "Channel", "Kind", "Status", + "Target", "Actor", "DurationMs", "HttpStatus", + }) + { + Assert.Contains(col, firstLine); + } + } + + [Fact] + public void Table_TruncatesLongTarget_WithEllipsis() + { + var formatter = new TableAuditFormatter(); + var output = new StringWriter(); + + var longTarget = new string('x', 200); + formatter.WritePage( + Events($"[{{\"occurredAtUtc\":\"2026-05-20T12:00:00Z\",\"channel\":\"OutboundApi\"," + + $"\"kind\":\"SyncCall\",\"status\":\"Delivered\",\"target\":\"{longTarget}\"," + + $"\"actor\":\"multi-role\"}}]"), + output); + + var text = output.ToString(); + Assert.Contains("…", text); + // The full untruncated target must not appear verbatim. + Assert.DoesNotContain(longTarget, text); + } + + [Fact] + public void Table_EmptyResultSet_RendersHeaderOnly_OrNoRowsMessage() + { + var formatter = new TableAuditFormatter(); + var output = new StringWriter(); + + formatter.WritePage(Events("[]"), output); + + var lines = output.ToString() + .Split('\n', StringSplitOptions.RemoveEmptyEntries); + // Header only — no data rows. (A header line is always emitted so the + // column shape is visible even with zero results.) + Assert.Single(lines); + Assert.Contains("OccurredAtUtc", lines[0]); + } + + [Fact] + public void Table_NullActor_RendersBlank() + { + var formatter = new TableAuditFormatter(); + var output = new StringWriter(); + + formatter.WritePage( + Events("[{\"occurredAtUtc\":\"2026-05-20T12:00:00Z\",\"channel\":\"InboundApi\"," + + "\"kind\":\"ApiCall\",\"status\":\"Delivered\",\"target\":\"key-1\"," + + "\"actor\":null}]"), + output); + + var lines = output.ToString() + .Split('\n', StringSplitOptions.RemoveEmptyEntries); + Assert.Equal(2, lines.Length); + // The data row must not contain the literal "null" for the actor column. + Assert.DoesNotContain("null", lines[1]); + Assert.Contains("InboundApi", lines[1]); + } + + [Fact] + public void Table_HeaderEmittedOncePerPage_DataRowsAligned() + { + var formatter = new TableAuditFormatter(); + var output = new StringWriter(); + + formatter.WritePage( + Events("[{\"occurredAtUtc\":\"2026-05-20T12:00:00Z\",\"channel\":\"OutboundApi\"," + + "\"kind\":\"SyncCall\",\"status\":\"Delivered\",\"target\":\"weather-api\"," + + "\"actor\":\"multi-role\",\"durationMs\":42,\"httpStatus\":200}," + + "{\"occurredAtUtc\":\"2026-05-20T12:01:00Z\",\"channel\":\"Notification\"," + + "\"kind\":\"Send\",\"status\":\"Failed\",\"target\":\"ops-list\"," + + "\"actor\":\"scheduler\",\"durationMs\":7}]"), + output); + + var lines = output.ToString() + .Split('\n', StringSplitOptions.RemoveEmptyEntries); + Assert.Equal(3, lines.Length); + Assert.Contains("weather-api", lines[1]); + Assert.Contains("ops-list", lines[2]); + } +}