refactor: model bin errors as neverthrow Result values (task 0003)
Split the view command out of the entry point and make its failure a value rather than an exception. index.ts is now scaffolding plus dispatch; view.ts owns the behaviour, returning Result<string, ViewError> (a plain-data tagged union) via neverthrow, with try/catch confined to a readFile adapter. The entry point matches the Result at the boundary. Because viewFile is a pure in-process function it is now unit-tested directly (and counted by coverage), while the subprocess tests stay as the end-to-end check. ADR 0008 records the errors-as-values convention.
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
"@kitchen-md/core": "workspace:*",
|
||||
"chalk": "^5.6.2",
|
||||
"commander": "^15.0.0",
|
||||
"neverthrow": "^8.2.0",
|
||||
"yaml": "^2.9.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
#!/usr/bin/env bun
|
||||
import { readFileSync } from "node:fs";
|
||||
import { parse } from "@kitchen-md/core";
|
||||
import { Command } from "commander";
|
||||
import { render } from "./render.ts";
|
||||
import { formatViewError, viewFile } from "./view.ts";
|
||||
|
||||
const program = new Command();
|
||||
|
||||
@@ -13,15 +11,13 @@ program
|
||||
.description("Render a Recipe File to the terminal")
|
||||
.argument("<file>", "path to a Recipe File")
|
||||
.action((file: string) => {
|
||||
let content: string;
|
||||
try {
|
||||
content = readFileSync(file, "utf8");
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
process.stderr.write(`kitchen: cannot read '${file}': ${reason}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(render(parse(content)));
|
||||
viewFile(file).match(
|
||||
(output) => process.stdout.write(output),
|
||||
(error) => {
|
||||
process.stderr.write(`kitchen: ${formatViewError(error)}\n`);
|
||||
process.exit(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
program.parse();
|
||||
|
||||
27
packages/bin/src/view.ts
Normal file
27
packages/bin/src/view.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { parse } from "@kitchen-md/core";
|
||||
import { err, ok, type Result } from "neverthrow";
|
||||
import { render } from "./render.ts";
|
||||
|
||||
export type ViewError = { tag: "read-failed"; path: string; cause: string };
|
||||
|
||||
export function viewFile(path: string): Result<string, ViewError> {
|
||||
return readFile(path).map((content) => render(parse(content)));
|
||||
}
|
||||
|
||||
export function formatViewError(error: ViewError): string {
|
||||
switch (error.tag) {
|
||||
case "read-failed":
|
||||
return `cannot read '${error.path}': ${error.cause}`;
|
||||
}
|
||||
}
|
||||
|
||||
// The one place a throwing API is turned into a Result; nothing above this leaks exceptions.
|
||||
function readFile(path: string): Result<string, ViewError> {
|
||||
try {
|
||||
return ok(readFileSync(path, "utf8"));
|
||||
} catch (error) {
|
||||
const cause = error instanceof Error ? error.message : String(error);
|
||||
return err({ tag: "read-failed", path, cause });
|
||||
}
|
||||
}
|
||||
41
packages/bin/src/view_test.ts
Normal file
41
packages/bin/src/view_test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { stripAnsi } from "./test-support.ts";
|
||||
import { formatViewError, viewFile } from "./view.ts";
|
||||
|
||||
const FIXTURE = join(import.meta.dir, "..", "..", "..", "fixtures", "prose.md");
|
||||
|
||||
describe("view", () => {
|
||||
test("returns Ok with rendered output for a real recipe", () => {
|
||||
const result = viewFile(FIXTURE);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
|
||||
const output = stripAnsi(result._unsafeUnwrap());
|
||||
expect(output).toContain("title: Buttered Toast");
|
||||
expect(output).toContain("servings: 2");
|
||||
expect(output).toMatch(/─+/);
|
||||
expect(output).toContain("Buttered Toast");
|
||||
expect(output).toContain("Method");
|
||||
expect(output).toContain("Toast the bread until golden on both sides.");
|
||||
expect(output).toContain("Cut into triangles and serve at once.");
|
||||
});
|
||||
|
||||
test("returns Err with a read-failed error for a missing file", () => {
|
||||
const result = viewFile("/no/such/kitchen-view-missing.md");
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
|
||||
const error = result._unsafeUnwrapErr();
|
||||
expect(error.tag).toBe("read-failed");
|
||||
expect(error.path).toBe("/no/such/kitchen-view-missing.md");
|
||||
expect(error.cause).toMatch(/ENOENT|no such file/i);
|
||||
});
|
||||
|
||||
test("formats a read-failed error as a human-readable message", () => {
|
||||
expect(formatViewError({ tag: "read-failed", path: "/tmp/x.md", cause: "boom" })).toBe(
|
||||
"cannot read '/tmp/x.md': boom",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user