From a171ca2f77defa52162d9036e355ccc3b03e7e04 Mon Sep 17 00:00:00 2001 From: joshwheelock Date: Wed, 19 Aug 2026 20:09:42 +0200 Subject: [PATCH] Add decision show command --- .../context/decisions/DecisionView.ts | 2 +- .../decisions/show/IShowDecisionGateway.ts | 6 + .../show/LocalShowDecisionGateway.ts | 18 +++ .../decisions/show/ShowDecisionController.ts | 11 ++ .../decisions/show/ShowDecisionRequest.ts | 3 + .../decisions/show/ShowDecisionResponse.ts | 5 + src/application/host/IApplicationContainer.ts | 2 + src/infrastructure/host/HostBuilder.ts | 9 ++ .../show/DecisionShowOutputBuilder.ts | 88 ++++++++++++++ .../commands/decisions/show/decision.show.ts | 63 ++++++++++ .../commands/registry/generated-commands.ts | 6 + .../show/LocalShowDecisionGateway.test.ts | 50 ++++++++ .../show/ShowDecisionController.test.ts | 31 +++++ tests/infrastructure/host/HostBuilder.test.ts | 9 ++ .../show/DecisionShowOutputBuilder.test.ts | 83 +++++++++++++ .../decisions/show/decision.show.test.ts | 114 ++++++++++++++++++ .../registry/RequiresProjectMetadata.test.ts | 7 ++ 17 files changed, 506 insertions(+), 1 deletion(-) create mode 100644 src/application/context/decisions/show/IShowDecisionGateway.ts create mode 100644 src/application/context/decisions/show/LocalShowDecisionGateway.ts create mode 100644 src/application/context/decisions/show/ShowDecisionController.ts create mode 100644 src/application/context/decisions/show/ShowDecisionRequest.ts create mode 100644 src/application/context/decisions/show/ShowDecisionResponse.ts create mode 100644 src/presentation/cli/commands/decisions/show/DecisionShowOutputBuilder.ts create mode 100644 src/presentation/cli/commands/decisions/show/decision.show.ts create mode 100644 tests/application/context/decisions/show/LocalShowDecisionGateway.test.ts create mode 100644 tests/application/context/decisions/show/ShowDecisionController.test.ts create mode 100644 tests/presentation/cli/commands/decisions/show/DecisionShowOutputBuilder.test.ts create mode 100644 tests/presentation/cli/commands/decisions/show/decision.show.test.ts diff --git a/src/application/context/decisions/DecisionView.ts b/src/application/context/decisions/DecisionView.ts index 4272f5cd..bfccea54 100644 --- a/src/application/context/decisions/DecisionView.ts +++ b/src/application/context/decisions/DecisionView.ts @@ -1,6 +1,6 @@ /** * Read model for Decision aggregate. - * Represents the materialized view stored in SQLite. + * Represents the storage-agnostic projected state exposed by decision read ports. */ export interface DecisionView { readonly decisionId: string; diff --git a/src/application/context/decisions/show/IShowDecisionGateway.ts b/src/application/context/decisions/show/IShowDecisionGateway.ts new file mode 100644 index 00000000..5b65ba50 --- /dev/null +++ b/src/application/context/decisions/show/IShowDecisionGateway.ts @@ -0,0 +1,6 @@ +import { ShowDecisionRequest } from "./ShowDecisionRequest.js"; +import { ShowDecisionResponse } from "./ShowDecisionResponse.js"; + +export interface IShowDecisionGateway { + showDecision(request: ShowDecisionRequest): Promise; +} diff --git a/src/application/context/decisions/show/LocalShowDecisionGateway.ts b/src/application/context/decisions/show/LocalShowDecisionGateway.ts new file mode 100644 index 00000000..a7fe83f8 --- /dev/null +++ b/src/application/context/decisions/show/LocalShowDecisionGateway.ts @@ -0,0 +1,18 @@ +import { IDecisionViewReader } from "../get/IDecisionViewReader.js"; +import { IShowDecisionGateway } from "./IShowDecisionGateway.js"; +import { ShowDecisionRequest } from "./ShowDecisionRequest.js"; +import { ShowDecisionResponse } from "./ShowDecisionResponse.js"; + +export class LocalShowDecisionGateway implements IShowDecisionGateway { + constructor(private readonly decisionViewReader: IDecisionViewReader) {} + + async showDecision(request: ShowDecisionRequest): Promise { + const [decision] = await this.decisionViewReader.findByIds([request.decisionId]); + + if (!decision) { + throw new Error(`Decision not found: ${request.decisionId}`); + } + + return { decision }; + } +} diff --git a/src/application/context/decisions/show/ShowDecisionController.ts b/src/application/context/decisions/show/ShowDecisionController.ts new file mode 100644 index 00000000..f6e22c38 --- /dev/null +++ b/src/application/context/decisions/show/ShowDecisionController.ts @@ -0,0 +1,11 @@ +import { IShowDecisionGateway } from "./IShowDecisionGateway.js"; +import { ShowDecisionRequest } from "./ShowDecisionRequest.js"; +import { ShowDecisionResponse } from "./ShowDecisionResponse.js"; + +export class ShowDecisionController { + constructor(private readonly gateway: IShowDecisionGateway) {} + + async handle(request: ShowDecisionRequest): Promise { + return this.gateway.showDecision(request); + } +} diff --git a/src/application/context/decisions/show/ShowDecisionRequest.ts b/src/application/context/decisions/show/ShowDecisionRequest.ts new file mode 100644 index 00000000..243276dd --- /dev/null +++ b/src/application/context/decisions/show/ShowDecisionRequest.ts @@ -0,0 +1,3 @@ +export interface ShowDecisionRequest { + readonly decisionId: string; +} diff --git a/src/application/context/decisions/show/ShowDecisionResponse.ts b/src/application/context/decisions/show/ShowDecisionResponse.ts new file mode 100644 index 00000000..a6c7ceb3 --- /dev/null +++ b/src/application/context/decisions/show/ShowDecisionResponse.ts @@ -0,0 +1,5 @@ +import { DecisionView } from "../DecisionView.js"; + +export interface ShowDecisionResponse { + readonly decision: DecisionView; +} diff --git a/src/application/host/IApplicationContainer.ts b/src/application/host/IApplicationContainer.ts index 9ddf107e..6fd8ee6d 100644 --- a/src/application/host/IApplicationContainer.ts +++ b/src/application/host/IApplicationContainer.ts @@ -127,6 +127,7 @@ import { ResumeWorkController } from "../context/work/resume/ResumeWorkControlle import { AddDecisionController } from "../context/decisions/add/AddDecisionController.js"; import { GetDecisionsController } from "../context/decisions/get/GetDecisionsController.js"; import { SearchDecisionsController } from "../context/decisions/search/SearchDecisionsController.js"; +import { ShowDecisionController } from "../context/decisions/show/ShowDecisionController.js"; import { ReverseDecisionController } from "../context/decisions/reverse/ReverseDecisionController.js"; import { RestoreDecisionController } from "../context/decisions/restore/RestoreDecisionController.js"; import { SupersedeDecisionController } from "../context/decisions/supersede/SupersedeDecisionController.js"; @@ -466,6 +467,7 @@ export interface IApplicationContainer { addDecisionController: AddDecisionController; getDecisionsController: GetDecisionsController; searchDecisionsController: SearchDecisionsController; + showDecisionController: ShowDecisionController; reverseDecisionController: ReverseDecisionController; restoreDecisionController: RestoreDecisionController; supersedeDecisionController: SupersedeDecisionController; diff --git a/src/infrastructure/host/HostBuilder.ts b/src/infrastructure/host/HostBuilder.ts index bf6e15c6..74c86c00 100644 --- a/src/infrastructure/host/HostBuilder.ts +++ b/src/infrastructure/host/HostBuilder.ts @@ -78,6 +78,8 @@ import { LocalGetDecisionsGateway } from "../../application/context/decisions/ge import { GetDecisionsController } from "../../application/context/decisions/get/GetDecisionsController.js"; import { LocalSearchDecisionsGateway } from "../../application/context/decisions/search/LocalSearchDecisionsGateway.js"; import { SearchDecisionsController } from "../../application/context/decisions/search/SearchDecisionsController.js"; +import { LocalShowDecisionGateway } from "../../application/context/decisions/show/LocalShowDecisionGateway.js"; +import { ShowDecisionController } from "../../application/context/decisions/show/ShowDecisionController.js"; import { ReverseDecisionCommandHandler } from "../../application/context/decisions/reverse/ReverseDecisionCommandHandler.js"; import { LocalReverseDecisionGateway } from "../../application/context/decisions/reverse/LocalReverseDecisionGateway.js"; import { ReverseDecisionController } from "../../application/context/decisions/reverse/ReverseDecisionController.js"; @@ -1458,6 +1460,12 @@ const audiencePainContextReader = new SqliteAudiencePainContextReader(this.db); const getDecisionsController = new GetDecisionsController( getDecisionsGateway ); + const showDecisionGateway = new LocalShowDecisionGateway( + decisionViewReader + ); + const showDecisionController = new ShowDecisionController( + showDecisionGateway + ); const searchDecisionsGateway = new LocalSearchDecisionsGateway( decisionViewReader ); @@ -2200,6 +2208,7 @@ const audiencePainContextReader = new SqliteAudiencePainContextReader(this.db); // Decision Controllers addDecisionController, getDecisionsController, + showDecisionController, searchDecisionsController, reverseDecisionController, restoreDecisionController, diff --git a/src/presentation/cli/commands/decisions/show/DecisionShowOutputBuilder.ts b/src/presentation/cli/commands/decisions/show/DecisionShowOutputBuilder.ts new file mode 100644 index 00000000..5546f0a6 --- /dev/null +++ b/src/presentation/cli/commands/decisions/show/DecisionShowOutputBuilder.ts @@ -0,0 +1,88 @@ +import chalk from "chalk"; +import { DecisionView } from "../../../../../application/context/decisions/DecisionView.js"; +import { SemanticColors, TuiGlyphs } from "../../../../shared/DesignTokens.js"; +import { TerminalOutput } from "../../../output/TerminalOutput.js"; +import { TerminalOutputBuilder } from "../../../output/TerminalOutputBuilder.js"; + +const decisionShowStyle = { + heading: chalk.hex(SemanticColors.headline).bold, + label: chalk.hex(SemanticColors.label), + primary: chalk.hex(SemanticColors.primary), + muted: chalk.hex(SemanticColors.muted), + error: chalk.hex(SemanticColors.error), +} as const; + +export class DecisionShowOutputBuilder { + private readonly builder = new TerminalOutputBuilder(); + + build(decision: DecisionView): TerminalOutput { + this.builder.reset(); + + const lines = [ + "", + `${decisionShowStyle.heading(TuiGlyphs.accentBar)} ${decisionShowStyle.heading("Architectural Decision")}`, + this.field("ID", decision.decisionId), + this.field("Title", decision.title), + this.field("Status", decision.status), + this.field("Context", decision.context), + this.field("Rationale", decision.rationale), + this.alternatives(decision.alternatives), + this.field("Consequences", decision.consequences), + this.field("Superseded by", decision.supersededBy), + this.field("Reversal reason", decision.reversalReason), + this.field("Reversed at", decision.reversedAt), + this.field("Version", decision.version), + this.field("Created at", decision.createdAt), + this.field("Updated at", decision.updatedAt), + ]; + + this.builder.addPrompt(lines.join("\n")); + return this.builder.build(); + } + + buildStructuredOutput(decision: DecisionView): TerminalOutput { + this.builder.reset(); + this.builder.addData(decision); + return this.builder.build(); + } + + buildNotFoundError(decisionId: string): TerminalOutput { + this.builder.reset(); + this.builder.addPrompt( + `${decisionShowStyle.error(TuiGlyphs.cross)} ${decisionShowStyle.error("Decision not found")}\n` + + `${decisionShowStyle.muted(`No decision exists with ID: ${decisionId}`)}` + ); + return this.builder.build(); + } + + buildFailureError(error: Error | string): TerminalOutput { + this.builder.reset(); + const details = error instanceof Error ? error.message : error; + this.builder.addPrompt( + `${decisionShowStyle.error(TuiGlyphs.cross)} ${decisionShowStyle.error("Failed to show decision")}\n` + + decisionShowStyle.muted(details) + ); + return this.builder.build(); + } + + private field(label: string, value: string | number | null): string { + const rendered = value === null + ? decisionShowStyle.muted("(null)") + : value === "" + ? decisionShowStyle.muted("(empty)") + : decisionShowStyle.primary(String(value)); + + return `${decisionShowStyle.label(`${label}:`)} ${rendered}`; + } + + private alternatives(values: readonly string[]): string { + const label = decisionShowStyle.label("Alternatives:"); + if (values.length === 0) { + return `${label} ${decisionShowStyle.muted("(empty)")}`; + } + + return `${label}\n${values + .map((value) => ` ${decisionShowStyle.muted(TuiGlyphs.bullet)} ${decisionShowStyle.primary(value === "" ? "(empty)" : value)}`) + .join("\n")}`; + } +} diff --git a/src/presentation/cli/commands/decisions/show/decision.show.ts b/src/presentation/cli/commands/decisions/show/decision.show.ts new file mode 100644 index 00000000..8366bf4c --- /dev/null +++ b/src/presentation/cli/commands/decisions/show/decision.show.ts @@ -0,0 +1,63 @@ +import { IApplicationContainer } from "../../../../../application/host/IApplicationContainer.js"; +import { Renderer } from "../../../rendering/Renderer.js"; +import { RenderData } from "../../../rendering/types.js"; +import { CommandMetadata } from "../../registry/CommandMetadata.js"; +import { DecisionShowOutputBuilder } from "./DecisionShowOutputBuilder.js"; + +export const metadata: CommandMetadata = { + description: "Display the complete architectural decision record", + category: "solution", + requiredOptions: [ + { + flags: "-i, --id ", + description: "ID of the decision to show", + }, + ], + examples: [ + { + command: "jumbo decision show --id dec_abc123", + description: "Show the complete decision record", + }, + { + command: "jumbo decision show --id dec_abc123 --format json", + description: "Show the complete decision record as JSON", + }, + ], + related: ["decisions list", "decisions search", "decision update"], + requiresProject: true, +}; + +export async function decisionShow( + options: { id: string }, + container: IApplicationContainer +): Promise { + const renderer = Renderer.getInstance(); + const outputBuilder = new DecisionShowOutputBuilder(); + + try { + const { decision } = await container.showDecisionController.handle({ + decisionId: options.id, + }); + + if (renderer.getConfig().format === "text") { + renderer.info(outputBuilder.build(decision).toHumanReadable()); + return; + } + + const dataSection = outputBuilder + .buildStructuredOutput(decision) + .getSections() + .find((section) => section.type === "data"); + + if (dataSection) { + renderer.data(dataSection.content as RenderData); + } + } catch (error) { + const output = error instanceof Error && error.message === `Decision not found: ${options.id}` + ? outputBuilder.buildNotFoundError(options.id) + : outputBuilder.buildFailureError(error instanceof Error ? error : String(error)); + + renderer.error(output.toHumanReadable()); + process.exitCode = 1; + } +} diff --git a/src/presentation/cli/commands/registry/generated-commands.ts b/src/presentation/cli/commands/registry/generated-commands.ts index d188e926..7a8d7d6c 100644 --- a/src/presentation/cli/commands/registry/generated-commands.ts +++ b/src/presentation/cli/commands/registry/generated-commands.ts @@ -33,6 +33,7 @@ import { decisionsList, metadata as decisionsListMeta } from '../../commands/dec import { decisionRestore, metadata as decisionRestoreMeta } from '../../commands/decisions/restore/decision.restore.js'; import { decisionReverse, metadata as decisionReverseMeta } from '../../commands/decisions/reverse/decision.reverse.js'; import { decisionsSearch, metadata as decisionsSearchMeta } from '../../commands/decisions/search/decisions.search.js'; +import { decisionShow, metadata as decisionShowMeta } from '../../commands/decisions/show/decision.show.js'; import { decisionSupersede, metadata as decisionSupersedeMeta } from '../../commands/decisions/supersede/decision.supersede.js'; import { decisionUpdate, metadata as decisionUpdateMeta } from '../../commands/decisions/update/decision.update.js'; import { dependencyAdd, metadata as dependencyAddMeta } from '../../commands/dependencies/add/dependency.add.js'; @@ -222,6 +223,11 @@ export const commands: RegisteredCommand[] = [ metadata: decisionsSearchMeta, handler: decisionsSearch }, + { + path: 'decision show', + metadata: decisionShowMeta, + handler: decisionShow + }, { path: 'decision supersede', metadata: decisionSupersedeMeta, diff --git a/tests/application/context/decisions/show/LocalShowDecisionGateway.test.ts b/tests/application/context/decisions/show/LocalShowDecisionGateway.test.ts new file mode 100644 index 00000000..d7b24eb6 --- /dev/null +++ b/tests/application/context/decisions/show/LocalShowDecisionGateway.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { DecisionView } from "../../../../../src/application/context/decisions/DecisionView.js"; +import { IDecisionViewReader } from "../../../../../src/application/context/decisions/get/IDecisionViewReader.js"; +import { LocalShowDecisionGateway } from "../../../../../src/application/context/decisions/show/LocalShowDecisionGateway.js"; + +describe("LocalShowDecisionGateway", () => { + let decisionViewReader: jest.Mocked; + let gateway: LocalShowDecisionGateway; + + const decision: DecisionView = { + decisionId: "dec_123", + title: "Use event sourcing", + context: "State changes need a durable audit trail.", + rationale: "Events preserve intent.", + alternatives: ["CRUD snapshots", "Change data capture"], + consequences: "Projection rebuilding is required.", + status: "active", + supersededBy: null, + reversalReason: null, + reversedAt: null, + version: 3, + createdAt: "2026-01-01T10:00:00.000Z", + updatedAt: "2026-02-01T11:00:00.000Z", + }; + + beforeEach(() => { + decisionViewReader = { + findAll: jest.fn(), + findByIds: jest.fn(), + search: jest.fn(), + }; + gateway = new LocalShowDecisionGateway(decisionViewReader); + }); + + it("retrieves one decision through findByIds", async () => { + decisionViewReader.findByIds.mockResolvedValue([decision]); + + await expect(gateway.showDecision({ decisionId: "dec_123" })).resolves.toEqual({ decision }); + expect(decisionViewReader.findByIds).toHaveBeenCalledWith(["dec_123"]); + expect(decisionViewReader.findAll).not.toHaveBeenCalled(); + expect(decisionViewReader.search).not.toHaveBeenCalled(); + }); + + it("fails clearly when the decision ID is unknown", async () => { + decisionViewReader.findByIds.mockResolvedValue([]); + + await expect(gateway.showDecision({ decisionId: "dec_missing" })) + .rejects.toThrow("Decision not found: dec_missing"); + }); +}); diff --git a/tests/application/context/decisions/show/ShowDecisionController.test.ts b/tests/application/context/decisions/show/ShowDecisionController.test.ts new file mode 100644 index 00000000..ebdac1dd --- /dev/null +++ b/tests/application/context/decisions/show/ShowDecisionController.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { DecisionView } from "../../../../../src/application/context/decisions/DecisionView.js"; +import { IShowDecisionGateway } from "../../../../../src/application/context/decisions/show/IShowDecisionGateway.js"; +import { ShowDecisionController } from "../../../../../src/application/context/decisions/show/ShowDecisionController.js"; + +describe("ShowDecisionController", () => { + it("delegates the typed request and returns the gateway response", async () => { + const decision: DecisionView = { + decisionId: "dec_123", + title: "Use event sourcing", + context: "Auditability is required.", + rationale: null, + alternatives: [], + consequences: null, + status: "active", + supersededBy: null, + reversalReason: null, + reversedAt: null, + version: 1, + createdAt: "2026-01-01T10:00:00.000Z", + updatedAt: "2026-01-01T10:00:00.000Z", + }; + const gateway: jest.Mocked = { + showDecision: jest.fn().mockResolvedValue({ decision }), + }; + const controller = new ShowDecisionController(gateway); + + await expect(controller.handle({ decisionId: "dec_123" })).resolves.toEqual({ decision }); + expect(gateway.showDecision).toHaveBeenCalledWith({ decisionId: "dec_123" }); + }); +}); diff --git a/tests/infrastructure/host/HostBuilder.test.ts b/tests/infrastructure/host/HostBuilder.test.ts index b25bc502..1c193ea2 100644 --- a/tests/infrastructure/host/HostBuilder.test.ts +++ b/tests/infrastructure/host/HostBuilder.test.ts @@ -5,6 +5,7 @@ import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals"; import { AuditRelationsController } from "../../../src/application/context/relations/audit/AuditRelationsController.js"; +import { ShowDecisionController } from "../../../src/application/context/decisions/show/ShowDecisionController.js"; import { HostBuilder } from "../../../src/infrastructure/host/HostBuilder.js"; import { SqliteRelationNodeCatalog } from "../../../src/infrastructure/context/relations/audit/SqliteRelationNodeCatalog.js"; import { MigrationRunner } from "../../../src/infrastructure/persistence/MigrationRunner.js"; @@ -46,4 +47,12 @@ describe("HostBuilder relation audit wiring", () => { }), ); }); + + it("exposes a usable decision-show controller backed by the decision view reader", async () => { + const container = await new HostBuilder(tempDirectory, db).build(); + + expect(container.showDecisionController).toBeInstanceOf(ShowDecisionController); + await expect(container.showDecisionController.handle({ decisionId: "dec_missing" })) + .rejects.toThrow("Decision not found: dec_missing"); + }); }); diff --git a/tests/presentation/cli/commands/decisions/show/DecisionShowOutputBuilder.test.ts b/tests/presentation/cli/commands/decisions/show/DecisionShowOutputBuilder.test.ts new file mode 100644 index 00000000..34ea6180 --- /dev/null +++ b/tests/presentation/cli/commands/decisions/show/DecisionShowOutputBuilder.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it } from "@jest/globals"; +import { DecisionView } from "../../../../../../src/application/context/decisions/DecisionView.js"; +import { DecisionShowOutputBuilder } from "../../../../../../src/presentation/cli/commands/decisions/show/DecisionShowOutputBuilder.js"; + +describe("DecisionShowOutputBuilder", () => { + let outputBuilder: DecisionShowOutputBuilder; + + const decision: DecisionView = { + decisionId: "dec_123", + title: "Use event sourcing", + context: "State changes need a durable audit trail without losing any historical intent.", + rationale: "Events preserve intent and enable deterministic projection rebuilding.", + alternatives: ["CRUD snapshots", "Change data capture"], + consequences: "Readers use asynchronously rebuilt projections.", + status: "superseded", + supersededBy: "dec_456", + reversalReason: "A complete reversal explanation", + reversedAt: "2026-02-01T11:00:00.000Z", + version: 7, + createdAt: "2026-01-01T10:00:00.000Z", + updatedAt: "2026-02-01T11:00:00.000Z", + }; + + beforeEach(() => { + outputBuilder = new DecisionShowOutputBuilder(); + }); + + it("renders every decision field without truncation", () => { + const text = outputBuilder.build(decision).toHumanReadable(); + + for (const value of [ + decision.decisionId, + decision.title, + decision.context, + decision.rationale, + ...decision.alternatives, + decision.consequences, + decision.status, + decision.supersededBy, + decision.reversalReason, + decision.reversedAt, + String(decision.version), + decision.createdAt, + decision.updatedAt, + ]) { + expect(text).toContain(value); + } + expect(text).not.toContain("..."); + }); + + it("represents null and empty values explicitly", () => { + const text = outputBuilder.build({ + ...decision, + context: "", + rationale: null, + alternatives: [], + consequences: null, + supersededBy: null, + reversalReason: null, + reversedAt: null, + }).toHumanReadable(); + + expect(text).toContain("(empty)"); + expect(text.match(/\(null\)/g)).toHaveLength(5); + }); + + it("builds one data section containing the complete DecisionView", () => { + const sections = outputBuilder.buildStructuredOutput(decision).getSections(); + + expect(sections).toHaveLength(1); + expect(sections[0]).toEqual({ type: "data", content: decision, metadata: undefined }); + expect(JSON.parse(JSON.stringify(sections[0].content))).toEqual(decision); + }); + + it("owns clear not-found and general failure copy", () => { + expect(outputBuilder.buildNotFoundError("dec_missing").toHumanReadable()) + .toContain("No decision exists with ID: dec_missing"); + expect(outputBuilder.buildFailureError(new Error("reader failed")).toHumanReadable()) + .toContain("Failed to show decision"); + expect(outputBuilder.buildFailureError("reader failed").toHumanReadable()) + .toContain("reader failed"); + }); +}); diff --git a/tests/presentation/cli/commands/decisions/show/decision.show.test.ts b/tests/presentation/cli/commands/decisions/show/decision.show.test.ts new file mode 100644 index 00000000..b6d724f6 --- /dev/null +++ b/tests/presentation/cli/commands/decisions/show/decision.show.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { DecisionView } from "../../../../../../src/application/context/decisions/DecisionView.js"; +import { ShowDecisionController } from "../../../../../../src/application/context/decisions/show/ShowDecisionController.js"; +import { IApplicationContainer } from "../../../../../../src/application/host/IApplicationContainer.js"; +import { decisionShow, metadata } from "../../../../../../src/presentation/cli/commands/decisions/show/decision.show.js"; +import { Renderer } from "../../../../../../src/presentation/cli/rendering/Renderer.js"; + +describe("decision.show command", () => { + let handle: jest.MockedFunction; + let container: Partial; + let stdout: jest.SpiedFunction; + let stderr: jest.SpiedFunction; + + const decision: DecisionView = { + decisionId: "dec_123", + title: "Use event sourcing", + context: "State changes need an audit trail.", + rationale: "Events preserve intent.", + alternatives: ["CRUD snapshots"], + consequences: "Projection rebuilding is required.", + status: "active", + supersededBy: null, + reversalReason: null, + reversedAt: null, + version: 2, + createdAt: "2026-01-01T10:00:00.000Z", + updatedAt: "2026-02-01T11:00:00.000Z", + }; + + beforeEach(() => { + handle = jest.fn(); + container = { + showDecisionController: { handle } as unknown as ShowDecisionController, + }; + stdout = jest.spyOn(console, "log").mockImplementation(() => undefined); + stderr = jest.spyOn(console, "error").mockImplementation(() => undefined); + process.exitCode = undefined; + }); + + afterEach(() => { + Renderer.reset(); + process.exitCode = undefined; + jest.restoreAllMocks(); + }); + + it("declares required ID metadata, examples, and project scope", () => { + expect(metadata.requiredOptions).toContainEqual(expect.objectContaining({ + flags: "-i, --id ", + })); + expect(metadata.examples).toEqual(expect.arrayContaining([ + expect.objectContaining({ command: expect.stringContaining("decision show --id") }), + ])); + expect(metadata.requiresProject).toBe(true); + }); + + it("delegates and renders rich text when text format is selected", async () => { + Renderer.configure({ format: "text" }); + handle.mockResolvedValue({ decision }); + + await decisionShow({ id: "dec_123" }, container as IApplicationContainer); + + expect(handle).toHaveBeenCalledWith({ decisionId: "dec_123" }); + expect(stdout).toHaveBeenCalledTimes(1); + expect(stdout.mock.calls[0][0]).toContain(decision.context); + expect(stderr).not.toHaveBeenCalled(); + }); + + it("renders exactly one complete JSON object when JSON is selected", async () => { + Renderer.configure({ format: "json" }); + handle.mockResolvedValue({ decision }); + + await decisionShow({ id: "dec_123" }, container as IApplicationContainer); + + expect(stdout).toHaveBeenCalledTimes(1); + expect(JSON.parse(String(stdout.mock.calls[0][0]))).toEqual(decision); + expect(stderr).not.toHaveBeenCalled(); + }); + + it("defaults non-TTY output to JSON when no format is supplied", async () => { + Renderer.reset(); + handle.mockResolvedValue({ decision }); + + await decisionShow({ id: "dec_123" }, container as IApplicationContainer); + + expect(stdout).toHaveBeenCalledTimes(1); + expect(JSON.parse(String(stdout.mock.calls[0][0]))).toEqual(decision); + }); + + it("reports unknown IDs only on stderr and sets a non-zero exit code", async () => { + Renderer.configure({ format: "json" }); + handle.mockRejectedValue(new Error("Decision not found: dec_missing")); + + await decisionShow({ id: "dec_missing" }, container as IApplicationContainer); + + expect(stdout).not.toHaveBeenCalled(); + expect(stderr).toHaveBeenCalledTimes(1); + expect(JSON.parse(String(stderr.mock.calls[0][0]))).toEqual(expect.objectContaining({ + error: expect.stringContaining("No decision exists with ID: dec_missing"), + })); + expect(process.exitCode).toBe(1); + }); + + it("routes unexpected failures through builder-owned error output", async () => { + Renderer.configure({ format: "text" }); + handle.mockRejectedValue(new Error("reader failed")); + + await decisionShow({ id: "dec_123" }, container as IApplicationContainer); + + expect(stdout).not.toHaveBeenCalled(); + expect(stderr.mock.calls[0][0]).toContain("Failed to show decision"); + expect(stderr.mock.calls[0][0]).toContain("reader failed"); + expect(process.exitCode).toBe(1); + }); +}); diff --git a/tests/presentation/cli/commands/registry/RequiresProjectMetadata.test.ts b/tests/presentation/cli/commands/registry/RequiresProjectMetadata.test.ts index 2df84bfc..90c643ce 100644 --- a/tests/presentation/cli/commands/registry/RequiresProjectMetadata.test.ts +++ b/tests/presentation/cli/commands/registry/RequiresProjectMetadata.test.ts @@ -49,6 +49,13 @@ describe("requiresProject metadata", () => { expect(command?.metadata.requiresProject).toBe(true); }); + it("includes decision show as an explicitly project-scoped generated command", () => { + const command = commands.find((c) => c.path === "decision show"); + + expect(command).toBeDefined(); + expect(command?.metadata.requiresProject).toBe(true); + }); + it("registers goal approve and no longer registers goal qualify", () => { const paths = commands.map((c) => c.path);