Skip to content

Commit 74cfe16

Browse files
committed
fix: convert directoryRules' raw parse/Error throws into CliError subclasses
writeDirectoryRules called DirectoryRulesSchema.parse() directly, so a malformed rule set (e.g. an empty path) crashed with a raw ZodError stack trace instead of the clean message every other config-write path in this codebase gives via ConfigValidationError. addDirectoryRule had the same underlying problem in a different shape: it threw a bare Error when neither --profile nor --identity was given, which also bypasses main()'s CliError-only clean-message handling. writeDirectoryRules now safeParses and throws ConfigValidationError, matching readJson/applyPatch/loadConfigFile's existing pattern. addDirectoryRule now throws a new DirectoryRuleMissingTargetError (extends CliError) instead of a bare Error.
1 parent 36ebbb7 commit 74cfe16

3 files changed

Lines changed: 27 additions & 7 deletions

File tree

src/cliError.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
33
import { CliError } from "./cliError";
44
import { IdentityAlreadyExistsError, IdentityNotFoundError, InvalidIdentityNameError } from "./identityManager";
55
import { InvalidCategoryNameError, ProfileAlreadyExistsError, ProfileNotFoundError } from "./configProfiles";
6-
import { DirectoryRuleNotFoundError } from "./directoryRules";
6+
import { DirectoryRuleMissingTargetError, DirectoryRuleNotFoundError } from "./directoryRules";
77
import { ForeignClaudeEntryError, UnsupportedShimSourceError } from "./claudeShim";
88
import { ConfigValidationError } from "./config/load";
99
import { InvalidCliCategoryError, InvalidCliEntryKeyError } from "./launcher/cliOverride";
@@ -23,6 +23,7 @@ describe("every CLI-facing error class extends CliError", () => {
2323
["ProfileAlreadyExistsError", () => new ProfileAlreadyExistsError("client-acme")],
2424
["InvalidCategoryNameError", () => new InvalidCategoryNameError("secret")],
2525
["DirectoryRuleNotFoundError", () => new DirectoryRuleNotFoundError("/some/path")],
26+
["DirectoryRuleMissingTargetError", () => new DirectoryRuleMissingTargetError()],
2627
["ForeignClaudeEntryError", () => new ForeignClaudeEntryError("/usr/local/bin/claude", "enable")],
2728
["UnsupportedShimSourceError", () => new UnsupportedShimSourceError("/some/source")],
2829
["ConfigValidationError", () => new ConfigValidationError("/some/config.json", [])],

src/directoryRules.test.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@ import path from "node:path";
44
import { afterEach, beforeEach, describe, expect, it } from "vitest";
55

66
import { buildLayoutPaths, type LayoutPaths } from "./paths";
7+
import { ConfigValidationError } from "./config/load";
78
import {
9+
DirectoryRuleMissingTargetError,
810
DirectoryRuleNotFoundError,
911
addDirectoryRule,
1012
listDirectoryRules,
1113
readDirectoryRules,
1214
removeDirectoryRule,
15+
writeDirectoryRules,
1316
} from "./directoryRules";
1417

1518
describe("directoryRules", () => {
@@ -55,8 +58,12 @@ describe("directoryRules", () => {
5558
});
5659
});
5760

58-
it("throws when neither --profile nor --identity is given", () => {
59-
expect(() => addDirectoryRule(paths, "~/work", {})).toThrow(/at least one/);
61+
it("throws DirectoryRuleMissingTargetError when neither --profile nor --identity is given", () => {
62+
expect(() => addDirectoryRule(paths, "~/work", {})).toThrow(DirectoryRuleMissingTargetError);
63+
});
64+
65+
it("throws ConfigValidationError, not a raw ZodError, for a rule set that fails DirectoryRulesSchema", () => {
66+
expect(() => writeDirectoryRules(paths, { rules: [{ path: "" }] })).toThrow(ConfigValidationError);
6067
});
6168

6269
it("appends a second rule for a different path", () => {

src/directoryRules.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Command } from "commander";
22

33
import { readJson, writeJsonAtomic } from "./config/store";
4+
import { ConfigValidationError } from "./config/load";
45
import { DirectoryRulesSchema, type DirectoryRule, type DirectoryRules } from "./config/schema";
56
import { CliError } from "./cliError";
67
import { realPromptsPort, runProfileWizard } from "./configure";
@@ -15,15 +16,26 @@ export class DirectoryRuleNotFoundError extends CliError {
1516
}
1617
}
1718

19+
/** Raised by `addDirectoryRule` when neither `--profile` nor `--identity` is given — a rule that pins neither would do nothing. */
20+
export class DirectoryRuleMissingTargetError extends CliError {
21+
constructor() {
22+
super("A directory rule must set at least one of --profile or --identity.");
23+
this.name = "DirectoryRuleMissingTargetError";
24+
}
25+
}
26+
1827
/** Reads `~/.claude-use/directory-rules.json`, or an empty rule set when the file does not exist yet. */
1928
export function readDirectoryRules(paths: LayoutPaths): DirectoryRules {
2029
return readJson(paths.directoryRulesFile, DirectoryRulesSchema) ?? { rules: [] };
2130
}
2231

23-
/** Validates and writes the whole `~/.claude-use/directory-rules.json` file. Exported so `src/configure.ts` can update a single rule's `categories`/`entries` in place without duplicating this validate-then-write step. */
32+
/** Validates and writes the whole `~/.claude-use/directory-rules.json` file. Exported so `src/configure.ts` can update a single rule's `categories`/`entries` in place without duplicating this validate-then-write step. Throws `ConfigValidationError` when `rules` fails `DirectoryRulesSchema`, rather than letting the underlying `ZodError` escape as an unhandled crash. */
2433
export function writeDirectoryRules(paths: LayoutPaths, rules: DirectoryRules): void {
25-
const validated = DirectoryRulesSchema.parse(rules);
26-
writeJsonAtomic(paths.directoryRulesFile, validated);
34+
const parsed = DirectoryRulesSchema.safeParse(rules);
35+
if (!parsed.success) {
36+
throw new ConfigValidationError(paths.directoryRulesFile, parsed.error.issues);
37+
}
38+
writeJsonAtomic(paths.directoryRulesFile, parsed.data);
2739
}
2840

2941
/** Lists every directory rule, in file order. */
@@ -44,7 +56,7 @@ export interface AddDirectoryRuleOptions {
4456
*/
4557
export function addDirectoryRule(paths: LayoutPaths, rulePath: string, options: AddDirectoryRuleOptions): DirectoryRule {
4658
if (options.configProfile === undefined && options.identity === undefined) {
47-
throw new Error("A directory rule must set at least one of --profile or --identity.");
59+
throw new DirectoryRuleMissingTargetError();
4860
}
4961
const current = readDirectoryRules(paths);
5062
const existingIndex = current.rules.findIndex((rule) => rule.path === rulePath);

0 commit comments

Comments
 (0)