diff --git a/src/cli.ts b/src/cli.ts index 4063ddd3..b6bebbcb 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -923,6 +923,7 @@ program .command("timeline") .description("Show recent mex event log entries") .option("--json", "Output events as JSON") + .option("--format ", "Output format: md for a Markdown table (piped into reports)") .option("--since ", "Filter from YYYY-MM-DD or relative Nd, e.g. 30d") .option("--type ", "Filter by event type") .option("--limit ", "Maximum number of entries", parsePositiveIntArg) diff --git a/src/events.ts b/src/events.ts index 361caf46..9d2f60a8 100644 --- a/src/events.ts +++ b/src/events.ts @@ -58,6 +58,8 @@ export interface LogOpts { export interface TimelineOpts { json?: boolean; + /** `"md"` renders a Markdown table instead of the terminal output (#55). */ + format?: string; since?: string; kind?: string; limit?: number; @@ -113,6 +115,11 @@ export async function runTimeline(config: MexConfig, opts: TimelineOpts = {}): P return; } + if (opts.format === "md") { + printTimelineMarkdown(filtered); + return; + } + if (filtered.length === 0) { console.log(chalk.dim("No events found.")); return; @@ -124,6 +131,25 @@ export async function runTimeline(config: MexConfig, opts: TimelineOpts = {}): P } } +/** + * Markdown rendering for `timeline --format md` — valid inside reports and + * standup notes. Pipes and line breaks are escaped so a message cannot break + * the table; the default terminal output is untouched (#55). + */ +function printTimelineMarkdown(filtered: EventEntry[]): void { + if (filtered.length === 0) { + console.log("_No events found._"); + return; + } + console.log("| Date | Type | Event | Files |"); + console.log("|---|---|---|---|"); + for (const e of filtered) { + const message = e.message.replace(/\|/g, "\\|").replace(/\r?\n/g, " "); + const files = e.files.length ? e.files.map((f) => `\`${f}\``).join(", ") : "—"; + console.log(`| ${e.timestamp.slice(0, 10)} | ${e.kind} | ${message} | ${files} |`); + } +} + export function readEvents(config: MexConfig): EventEntry[] { const file = eventLogPath(config); if (!existsSync(file)) return []; diff --git a/test/events.test.ts b/test/events.test.ts index a7b25a2b..253274a3 100644 --- a/test/events.test.ts +++ b/test/events.test.ts @@ -86,4 +86,32 @@ describe("events", () => { await runTimeline(config, { json: true }); expect(spy.mock.calls.at(-1)?.[0]).toContain('"events"'); }); + + it("timeline --format md emits a valid Markdown table (#55)", async () => { + await runLog(config, "chose | the | bounded resolver", { kind: "decision", files: ["ROUTER.md"] }); + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); + await runTimeline(config, { format: "md" }); + const lines = spy.mock.calls.map((call) => String(call[0])); + expect(lines[0]).toBe("| Date | Type | Event | Files |"); + expect(lines[1]).toBe("|---|---|---|---|"); + const row = lines[2]!; + expect(row).toMatch(/^\| \d{4}-\d{2}-\d{2} \| decision \| /); + expect(row).toContain("chose \\| the \\| bounded resolver"); + expect(row).toContain("`ROUTER.md`"); + }); + + it("timeline --format md emits a placeholder for an empty log", async () => { + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); + await runTimeline(config, { format: "md" }); + expect(spy.mock.calls.at(-1)?.[0]).toBe("_No events found._"); + }); + + it("timeline default output is unchanged when --format is absent", async () => { + await runLog(config, "plain note", { kind: "note" }); + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); + await runTimeline(config, {}); + const rendered = spy.mock.calls.map((call) => String(call[0])).join("\n"); + expect(rendered).toContain("plain note"); + expect(rendered).not.toContain("|---"); + }); });