Files
Move/src/configFile.ts
T
nokeo08 8eac51cd45 Extract CliError into src/errors.ts
CliError used to live in cli.ts and was imported by configFile.ts purely
to grab a one-line class — the dependency arrow said 'config-file loader
depends on the CLI parser' when really it just needed a shared error
type.

Moving CliError to its own errors.ts module flattens the graph:

  errors.ts  (no internal deps)
    |
    +-- cli.ts
    +-- configFile.ts
    +-- config.ts  (will use it in the next commit)

cli.ts re-exports CliError for any consumer that prefers to keep
importing it from there; the canonical home is now errors.ts.
2026-06-17 16:22:33 -05:00

139 lines
4.7 KiB
TypeScript

/**
* configFile.ts
* -------------
* JSON config file loading + strict validation.
*
* Default path: ${XDG_CONFIG_HOME:-$HOME/.config}/move/config.json
*
* Schema (all keys optional; matching CLI flag names and units):
*
* moveInterval number seconds, positive
* checkInterval number seconds, positive
* stepDelay number milliseconds, positive
* stepCount number pixels, positive
* verbose boolean
*
* Unknown keys, wrong types, and non-positive numerics are rejected with a
* `CliError` so the entry point can exit 2 (user error) with a clear
* message pointing at the offending file.
*
* Return semantics:
* - `null` when no `explicitPath` was passed and the default path does
* not exist. This is the "user has no config" happy path.
* - A `ConfigOverrides` when a file was found and validated.
* - Throws `CliError` if a problem is detected (missing explicit path,
* bad JSON, wrong shape, unknown keys, invalid values).
*/
import { existsSync, readFileSync, statSync } from "node:fs";
import { defaultConfigPath, type ConfigOverrides } from "./config.ts";
import { CliError } from "./errors.ts";
const ALLOWED_KEYS: ReadonlySet<string> = new Set<string>([
"moveInterval",
"checkInterval",
"stepDelay",
"stepCount",
"verbose",
]);
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function requirePositiveNumber(name: string, raw: unknown, path: string): number {
if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) {
throw new CliError(
`invalid value for '${name}' in ${path}: ${JSON.stringify(raw)} (expected a positive number)`,
);
}
return raw;
}
function requireBoolean(name: string, raw: unknown, path: string): boolean {
if (typeof raw !== "boolean") {
throw new CliError(
`invalid value for '${name}' in ${path}: ${JSON.stringify(raw)} (expected a boolean)`,
);
}
return raw;
}
/**
* Load and validate the config file. See module docstring for return
* semantics.
*
* @param explicitPath - If provided (e.g., from `--config`), the file
* must exist and validate. If `undefined`, fall
* back to `defaultConfigPath()`; a missing default
* file is silent (returns `null`).
*/
export function loadConfigFile(explicitPath: string | undefined): ConfigOverrides | null {
const required: boolean = explicitPath !== undefined;
const path: string = explicitPath ?? defaultConfigPath();
if (!existsSync(path)) {
if (required) {
throw new CliError(`config file not found: ${path}`);
}
return null;
}
if (!statSync(path).isFile()) {
throw new CliError(`config path is not a regular file: ${path}`);
}
let raw: string;
try {
raw = readFileSync(path, "utf-8");
} catch (err: unknown) {
const msg: string = err instanceof Error ? err.message : String(err);
throw new CliError(`could not read config file ${path}: ${msg}`);
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (err: unknown) {
const msg: string = err instanceof Error ? err.message : String(err);
throw new CliError(`config file ${path} is not valid JSON: ${msg}`);
}
if (!isPlainObject(parsed)) {
throw new CliError(`config file ${path} must contain a JSON object at the root`);
}
// Strict mode: reject any key we don't know about. Catches typos like
// 'movInterval' that would otherwise sail through silently.
for (const key of Object.keys(parsed)) {
if (!ALLOWED_KEYS.has(key)) {
const allowed: string = [...ALLOWED_KEYS].join(", ");
throw new CliError(`unknown key '${key}' in ${path} (allowed: ${allowed})`);
}
}
return {
moveInterval:
"moveInterval" in parsed
? requirePositiveNumber("moveInterval", parsed.moveInterval, path)
: undefined,
checkInterval:
"checkInterval" in parsed
? requirePositiveNumber("checkInterval", parsed.checkInterval, path)
: undefined,
stepDelay:
"stepDelay" in parsed
? requirePositiveNumber("stepDelay", parsed.stepDelay, path)
: undefined,
stepCount:
"stepCount" in parsed
? requirePositiveNumber("stepCount", parsed.stepCount, path)
: undefined,
verbose:
"verbose" in parsed
? requireBoolean("verbose", parsed.verbose, path)
: undefined,
};
}