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]);
+ }
+}