feat(cli): table output formatter for audit events (#23 M8)

This commit is contained in:
Joseph Doherty
2026-05-20 22:00:57 -04:00
parent 4b3a692170
commit d40ee85e14
3 changed files with 217 additions and 7 deletions

View File

@@ -29,19 +29,16 @@ public sealed class JsonLinesAuditFormatter : IAuditFormatter
}
/// <summary>
/// Resolves an <see cref="IAuditFormatter"/> for a given <c>--format</c> value. The
/// table formatter is filled in by Bundle C; until then <c>--format table</c> falls back
/// to JSONL with a one-time notice so the flag is wired but not silently broken.
/// Resolves an <see cref="IAuditFormatter"/> for a given <c>--format</c> value:
/// <c>table</c> renders a column-aligned text table (<see cref="TableAuditFormatter"/>),
/// any other value (including <c>json</c>) renders JSONL.
/// </summary>
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();
}

View File

@@ -0,0 +1,96 @@
using System.Text.Json;
namespace ScadaLink.CLI.Commands;
/// <summary>
/// Human-readable table formatter for <c>audit query --format table</c> (Audit Log
/// #23 M8-T6). Renders each fetched page as a column-aligned text table with a fixed
/// column set (<see cref="Columns"/>). Long free-text fields (Target, Actor) are
/// truncated with an ellipsis so columns stay aligned regardless of payload size.
/// </summary>
/// <remarks>
/// A header row is emitted once per page (matching the streamable, page-at-a-time
/// contract of <see cref="IAuditFormatter"/>). An empty page emits the header only,
/// so the column shape is visible even with zero results.
/// </remarks>
public sealed class TableAuditFormatter : IAuditFormatter
{
/// <summary>JSON property name (camelCase, as the server serializes it) → column header.</summary>
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<JsonElement> events, TextWriter output)
{
// Build every cell first so column widths account for the actual data.
var rows = new List<string[]>(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);
}
/// <summary>
/// Extracts a cell value for <paramref name="property"/> from an audit event.
/// A missing property or a JSON <c>null</c> renders as an empty string (never
/// the literal text "null").
/// </summary>
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();
}
/// <summary>
/// Truncates <paramref name="value"/> to <paramref name="maxWidth"/> characters,
/// replacing the tail with a single-character ellipsis so the column stays aligned.
/// </summary>
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<string> 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();
}
}

View File

@@ -0,0 +1,117 @@
using System.Text.Json;
using ScadaLink.CLI.Commands;
namespace ScadaLink.CLI.Tests.Commands;
/// <summary>
/// Tests for the <c>table</c> output formatter of the <c>scadalink audit query</c>
/// subcommand (Audit Log #23 M8-T6): header rendering, long-field truncation, the
/// empty-result-set case, and null-actor handling.
/// </summary>
public class AuditTableFormatterTests
{
private static IReadOnlyList<JsonElement> 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]);
}
}