avtc-pi-settings-ui 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,421 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
+
4
+ /**
5
+ * Schema-driven settings normalization.
6
+ *
7
+ * Replaces per-field switch-based validation with a single loop over the schema.
8
+ */
9
+
10
+ import { createLogger, type Logger } from "avtc-pi-logger";
11
+ import type {
12
+ PresetElement,
13
+ PresetItem,
14
+ PresetPair,
15
+ PresetsSource,
16
+ PresetValue,
17
+ SettingSchema,
18
+ SettingsSchema,
19
+ } from "./schema.js";
20
+ import type { TypeContext } from "./type-definitions.js";
21
+ import { getTypeDefinition } from "./type-definitions.js";
22
+
23
+ /**
24
+ * Module-private sentinel for an invalid result from the core resolver.
25
+ * The load wrapper maps INVALID → default; the update wrapper maps INVALID → no update.
26
+ */
27
+ const INVALID = Symbol("invalid");
28
+
29
+ /**
30
+ * Best-effort logger for rejected settings values. Created lazily so importing this module does
31
+ * no file work; the first rejection (load or update) initializes the writer. The logger never
32
+ * throws — a logging failure cannot break settings load.
33
+ */
34
+ const NO_LOGGER_OPTIONS: Parameters<typeof createLogger>[1] = null;
35
+ let _logger: Logger | null = null;
36
+ function logger(): Logger {
37
+ if (_logger === null) _logger = createLogger("avtc-pi-settings-ui", NO_LOGGER_OPTIONS);
38
+ return _logger;
39
+ }
40
+
41
+ /**
42
+ * Resolve value aliases for a setting. If the raw value matches a valueAlias key,
43
+ * returns the mapped raw value; otherwise returns the raw value unchanged.
44
+ */
45
+ export function resolveValueAlias(rawValue: unknown, setting: SettingSchema): unknown {
46
+ if (typeof rawValue === "string" && setting.valueAliases && rawValue in setting.valueAliases) {
47
+ const alias = setting.valueAliases[rawValue];
48
+ if (alias !== undefined) return alias;
49
+ }
50
+ return rawValue;
51
+ }
52
+
53
+ /**
54
+ * Build the `TypeContext` for a setting — presets (as PresetPair[]) + min/max. A custom type's
55
+ * parse/format derives the null label itself from ctx.presets (the pair whose value is null).
56
+ */
57
+ function buildTypeContext(setting: SettingSchema): TypeContext {
58
+ return { presets: resolveStaticPresets(setting), min: setting.min, max: setting.max };
59
+ }
60
+
61
+ /**
62
+ * The core single-setting resolver — the ONE gate used by load (`resolveValue`), update
63
+ * (`updateSetting`), and env-var deserialize (which calls `normalizeFromSchema`).
64
+ * Returns the resolved value, or the `INVALID` sentinel when the raw value is rejected.
65
+ *
66
+ * 8-step gate:
67
+ * 1. aliases 2. valueAliases 3. null-label string → null
68
+ 4. null validity (null-preset — nullLabel defined)
69
+ 5. parse-if-string (typeDef.parse — the type→parser registry)
70
+ 6. type-match (`typeof === valueType`) 7. finite (number/duration) 8. `[min,max]` bounds
71
+ */
72
+ export function resolveSettingValue(raw: Record<string, unknown>, setting: SettingSchema): unknown {
73
+ // 1. field aliases (old keys → this setting's id)
74
+ let value = raw[setting.id];
75
+ if (value === undefined && setting.aliases) {
76
+ for (const alias of setting.aliases) {
77
+ if (raw[alias] !== undefined) {
78
+ value = raw[alias];
79
+ break;
80
+ }
81
+ }
82
+ }
83
+
84
+ // 2. value aliases (old value renames) — only for string values
85
+ if (typeof value === "string") {
86
+ value = resolveValueAlias(value, setting);
87
+ }
88
+
89
+ // 3. null-label string → null (e.g. stored/typed "Infinite"/"Never"/"ask" → null).
90
+ const nullLabel = resolveNullLabel(setting);
91
+ if (typeof value === "string" && nullLabel !== undefined && value === nullLabel) {
92
+ value = null;
93
+ }
94
+
95
+ const typeDef = getTypeDefinition(setting.type);
96
+
97
+ // 4. null validity — keep null iff a null-preset exists (nullLabel derived from the effective
98
+ // presets). A type with no null-preset rejects null. Legitimate nulls never reach parse.
99
+ if (value === null) {
100
+ return nullLabel !== undefined ? null : INVALID;
101
+ }
102
+
103
+ // 5. parse-if-string. Parse is string-only by signature; raw numbers/booleans
104
+ // never enter it.
105
+ if (typeof value === "string") {
106
+ const p = typeDef.parse(value, buildTypeContext(setting));
107
+ if (p === undefined) return INVALID;
108
+ value = p;
109
+ }
110
+
111
+ // 6. type-match — typeof must equal the type's declared valueType.
112
+ if (typeof value !== typeDef.valueType) return INVALID;
113
+
114
+ // 7. finite check (number/duration) — Infinity/NaN must not pass the bounds range test.
115
+ if (typeDef.valueType === "number" && !Number.isFinite(value as number)) return INVALID;
116
+
117
+ // 8. bounds — only for bounded types with a declared min/max.
118
+ if (typeDef.bounded === true) {
119
+ if (setting.min !== undefined && (value as number) < setting.min) return INVALID;
120
+ if (setting.max !== undefined && (value as number) > setting.max) return INVALID;
121
+ }
122
+
123
+ return value;
124
+ }
125
+
126
+ /**
127
+ * Resolve a value from raw data for a single setting (LOAD disposition).
128
+ * Thin wrapper over the core resolver: INVALID → default. Logs the rejection only when a value
129
+ * was actually PRESENT (an absent key is the normal default-fill path — not a rejection).
130
+ */
131
+ function resolveValue(raw: Record<string, unknown>, setting: SettingSchema): unknown {
132
+ const result = resolveSettingValue(raw, setting);
133
+ if (result === INVALID) {
134
+ if (isSettingPresent(raw, setting)) logRejection(raw, setting);
135
+ return setting.defaultValue;
136
+ }
137
+ return result;
138
+ }
139
+
140
+ /** True when the raw record carries a value for this setting (by id or any alias). */
141
+ function isSettingPresent(raw: Record<string, unknown>, setting: SettingSchema): boolean {
142
+ if (raw[setting.id] !== undefined) return true;
143
+ return setting.aliases?.some((alias) => raw[alias] !== undefined) ?? false;
144
+ }
145
+
146
+ /**
147
+ * Emit a best-effort warn log when a setting value is rejected at load. Includes the valid
148
+ * options when available — preset values for an enum, or the [min,max] bounds for a bounded
149
+ * number/duration — so a silent reset-to-default is diagnosable. Never throws.
150
+ */
151
+ function logRejection(raw: Record<string, unknown>, setting: SettingSchema): void {
152
+ try {
153
+ const rawValue = raw[setting.id];
154
+ const valid = validOptionsHint(setting);
155
+ const suffix = valid !== null ? ` (valid: ${valid})` : "";
156
+ // The raw value is interpolated verbatim — avtc-pi-logger sanitizes control chars (line
157
+ // forging) and truncates (runaway length) at the emit boundary, so this stays single-line.
158
+ logger().warn(`Setting '${setting.id}' rejected value '${String(rawValue)}'${suffix} - reset to default`);
159
+ } catch {
160
+ // logging is best-effort; never break a load on a logging failure
161
+ }
162
+ }
163
+
164
+ /**
165
+ * A human-readable hint of the acceptable values for a setting, or null when no hint applies
166
+ * (e.g. a free-form string). Enum → comma-joined preset values; bounded number/duration → the
167
+ * [min,max] range (ASCII). Resolver-function presets (model list) yield no static values → null.
168
+ */
169
+ function validOptionsHint(setting: SettingSchema): string | null {
170
+ const pairs = resolveStaticPresets(setting);
171
+ if (pairs.length > 0) {
172
+ return pairs.map(([, value]) => String(value)).join(", ");
173
+ }
174
+ const typeDef = getTypeDefinition(setting.type);
175
+ if (typeDef.bounded === true && (setting.min !== undefined || setting.max !== undefined)) {
176
+ const lower = setting.min !== undefined ? String(setting.min) : "-inf";
177
+ const upper = setting.max !== undefined ? String(setting.max) : "inf";
178
+ return `${lower}-${upper}`;
179
+ }
180
+ return null;
181
+ }
182
+
183
+ /**
184
+ * Test whether a `resolveSettingValue` result is the INVALID sentinel. The sentinel itself is
185
+ * module-private; this predicate is the public seam so `updateSetting` (factory.ts) can apply the
186
+ * update disposition (INVALID → no-update) without re-implementing the gate. (one gate)
187
+ */
188
+ export function isInvalidResult(value: unknown): boolean {
189
+ return value === INVALID;
190
+ }
191
+
192
+ /**
193
+ * Normalize raw settings using schema definitions.
194
+ * Iterates schema entries, checks aliases, applies parsers, fills defaults.
195
+ * Replaces N-field switch statements with a single data-driven loop.
196
+ */
197
+ export function normalizeFromSchema(raw: Record<string, unknown>, schema: SettingsSchema): Record<string, unknown> {
198
+ const result: Record<string, unknown> = {};
199
+
200
+ for (const setting of schema.settings) {
201
+ result[setting.id] = resolveValue(raw, setting);
202
+ }
203
+
204
+ return result;
205
+ }
206
+
207
+ /**
208
+ * Validate a schema's structural integrity at registration (`createSettingsExtension` — fail-fast
209
+ * so a consumer's malformed schema surfaces immediately, not as a mysterious runtime gap). Checks:
210
+ * (a) preset strings — every static-preset setting's bare strings parse
211
+ * (`normalizePresetElements` throws on an unparseable one), eagerly populating the cache so a
212
+ * runtime resolution never re-throws;
213
+ * (b) orphans — every setting id appears in some tab;
214
+ * (c) unresolved ids — every `tab.settingId` references a defined setting.
215
+ * Duplicate setting ids are also rejected (ambiguous lookup). Throws on the first violation.
216
+ */
217
+ export function validateSchema(schema: SettingsSchema): void {
218
+ // (a) preset strings (also eagerly populates the static-presets cache)
219
+ for (const setting of schema.settings) {
220
+ resolveStaticPresets(setting);
221
+ }
222
+
223
+ const settingIds = new Set<string>();
224
+ for (const setting of schema.settings) {
225
+ if (settingIds.has(setting.id)) {
226
+ throw new Error(`Schema: duplicate setting id '${setting.id}'`);
227
+ }
228
+ settingIds.add(setting.id);
229
+ }
230
+
231
+ // (b) every setting placed in some tab
232
+ const referenced = new Set<string>();
233
+ for (const tab of schema.tabs) {
234
+ for (const id of tab.settingIds) referenced.add(id);
235
+ }
236
+ for (const id of settingIds) {
237
+ if (!referenced.has(id)) {
238
+ throw new Error(`Schema: setting '${id}' is not placed in any tab`);
239
+ }
240
+ }
241
+
242
+ // (c) every tab.settingId references a defined setting
243
+ for (const tab of schema.tabs) {
244
+ for (const id of tab.settingIds) {
245
+ if (!settingIds.has(id)) {
246
+ throw new Error(`Schema: tab '${tab.label}' references unknown setting '${id}'`);
247
+ }
248
+ }
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Apply post-normalization clamps for settings with inter-field constraints
254
+ * that cannot be expressed per-field in the schema.
255
+ */
256
+ export function clampSettings(
257
+ result: Record<string, unknown>,
258
+ clampFn: ((result: Record<string, unknown>) => void) | undefined,
259
+ ): void {
260
+ clampFn?.(result);
261
+ }
262
+
263
+ /**
264
+ * Get the effective value for a setting from raw data.
265
+ * Returns the default if the raw value is invalid.
266
+ */
267
+ export function getEffectiveValue(raw: Record<string, unknown>, setting: SettingSchema): unknown {
268
+ return resolveValue(raw, setting);
269
+ }
270
+
271
+ /**
272
+ * Compute the raw internal string value for a setting.
273
+ * Converts null to the appropriate sentinel string.
274
+ */
275
+ export function computeRawInternalValue(settings: Record<string, unknown>, setting: SettingSchema): string {
276
+ const value = settings[setting.id];
277
+ if (value === null || value === undefined) {
278
+ // Display fallback only — the null label for nullable settings, or "Infinite" defensively
279
+ // for non-nullable settings that shouldn't hold null anyway.
280
+ return resolveNullLabel(setting) ?? "Infinite";
281
+ }
282
+ return String(value);
283
+ }
284
+
285
+ // ── Preset resolvers (single source: `presets` pairs, resolved via the effective seam) ──
286
+
287
+ /**
288
+ * Per-setting cache of normalized static presets. `SettingSchema` refs are stable, so a WeakMap
289
+ * lets the gate resolve a setting's pairs once instead of re-normalizing (re-parsing bare
290
+ * strings) on every load. Populated eagerly by `createSettingsExtension` schema validation.
291
+ */
292
+ const staticPresetsCache = new WeakMap<SettingSchema, PresetPair[]>();
293
+
294
+ /** Type guard: a `PresetElement` is a pair when it is an array (a bare value is never array-typed). */
295
+ function isPresetPair(el: PresetElement): el is PresetPair {
296
+ return Array.isArray(el);
297
+ }
298
+
299
+ /**
300
+ * Normalize a `PresetElement[]` (bare values OR full pairs, possibly MIXED) into `PresetPair[]`
301
+ * at the single resolution seam. Bare string → type-aware `[s, typeDef.parse(s, ctx)]` (duration
302
+ * "30m"→1800000ms, string→identity via an empty-presets ctx, custom types free); bare
303
+ * number/boolean/null → `[String(v), v]`; a full `PresetPair` → as-is. An unparseable bare string
304
+ * THROWS — caught at registration (createSettingsExtension schema validation → fail-fast) or by a
305
+ * resolver's degrade-to-empty for async sources. Downstream stays `PresetPair[]`-based.
306
+ */
307
+ export function normalizePresetElements(elements: readonly PresetElement[], setting: SettingSchema): PresetPair[] {
308
+ const typeDef = getTypeDefinition(setting.type);
309
+ // Empty-presets ctx so the string type's closed-enum parse degrades to identity (a bare enum
310
+ // value like "high" becomes ["high","high"], not a membership-rejection). number/duration parses
311
+ // ignore presets; compact-threshold falls back to its numeric path.
312
+ const ctx: TypeContext = { presets: [], min: setting.min, max: setting.max };
313
+ const result: PresetPair[] = [];
314
+ for (const el of elements) {
315
+ if (isPresetPair(el)) {
316
+ result.push(el);
317
+ continue;
318
+ }
319
+ if (typeof el === "string") {
320
+ const parsed = typeDef.parse(el, ctx);
321
+ if (parsed === undefined) {
322
+ throw new Error(`Setting '${setting.id}': preset '${el}' is not a valid ${setting.type}`);
323
+ }
324
+ result.push([el, parsed as PresetValue]);
325
+ } else {
326
+ // number | boolean | null → [String(v), v]
327
+ result.push([String(el), el]);
328
+ }
329
+ }
330
+ return result;
331
+ }
332
+
333
+ /**
334
+ * Resolve the EFFECTIVE source of presets for a setting: `setting.presets ?? typeDef.presets`.
335
+ * This is the single source of truth for the override pick — the gate and the UI both go through
336
+ * it. A resolver-function source is flagged `isFunction: true` with empty `pairs` (the gate
337
+ * cannot call a resolver, so it treats it as no presets at load; the UI resolves it once at
338
+ * modal-open). A static-array source yields the pairs directly. Absent at both levels → empty.
339
+ */
340
+ export function resolveEffectivePresets(setting: SettingSchema): {
341
+ isFunction: boolean;
342
+ pairs: PresetPair[];
343
+ } {
344
+ const src: PresetsSource | undefined = setting.presets ?? getTypeDefinition(setting.type).presets;
345
+ if (src === undefined) return { isFunction: false, pairs: [] };
346
+ if (typeof src === "function") return { isFunction: true, pairs: [] };
347
+ // Static array — normalize PresetElement[] → PresetPair[] once (cached on the stable schema ref).
348
+ // Validation at createSettingsExtension forces this normalization eagerly and throws on a bad
349
+ // bare string, so a runtime call never re-throws (the cache is already populated).
350
+ let cached = staticPresetsCache.get(setting);
351
+ if (!cached) {
352
+ cached = normalizePresetElements(src, setting);
353
+ staticPresetsCache.set(setting, cached);
354
+ }
355
+ return { isFunction: false, pairs: [...cached] };
356
+ }
357
+
358
+ /**
359
+ * Resolve the STATIC presets for a setting — the pairs the gate can use at load. Resolver-function
360
+ * sources collapse to `[]` here (resolved later by the UI); static-array sources yield their
361
+ * pairs. Thin wrapper over `resolveEffectivePresets` so call sites that only need the pairs keep
362
+ * a stable shape.
363
+ */
364
+ export function resolveStaticPresets(setting: SettingSchema): PresetPair[] {
365
+ return resolveEffectivePresets(setting).pairs;
366
+ }
367
+
368
+ /**
369
+ * The display label for the null value — the label of the preset pair whose value is null.
370
+ * Returns `undefined` when the setting is NOT nullable (no null pair): callers use this to
371
+ * decide whether null handling applies at all, so a non-nullable timeout correctly rejects
372
+ * "Infinite". For a well-formed nullable setting, a null pair always exists, so the label
373
+ * is fully derived from `presets`. Reads the EFFECTIVE static presets (setting override OR
374
+ * type-def default); resolver-function sources correctly yield `undefined` at load (their null
375
+ * label is resolved from pairs at UI time).
376
+ */
377
+ export function resolveNullLabel(setting: SettingSchema): string | undefined {
378
+ return resolveNullLabelFromPairs(resolveStaticPresets(setting));
379
+ }
380
+
381
+ /** Serialize a preset value (string | number | boolean | null) to its internal string form. */
382
+ function serializePresetValue(value: string | number | boolean | null, nullLabel: string): string {
383
+ return value === null ? nullLabel : String(value);
384
+ }
385
+
386
+ /**
387
+ * The display label for the null value, derived directly from a pairs array. Pure pair-level
388
+ * helper shared by `resolveNullLabel` (via `resolveStaticPresets`) and the UI's resolved-pairs
389
+ * path (a resolver's `[["Default", null], …]` is only available after resolution at open).
390
+ * Returns `undefined` when no pair has a null value.
391
+ */
392
+ export function resolveNullLabelFromPairs(pairs: readonly PresetPair[]): string | undefined {
393
+ for (const [label, value] of pairs) {
394
+ if (value === null) return label;
395
+ }
396
+ return undefined;
397
+ }
398
+
399
+ /**
400
+ * Build the full `PresetItem[]` (label + rawValue + displayValue) from a pairs array, in display
401
+ * order. Pure pair-level derivation shared by the gate (via `buildPresetItems(setting)`) and the
402
+ * UI's resolved-pairs path. The displayValue is the serialized string form (the null pair
403
+ * serializes to its label, or "Infinite" defensively when no null pair exists).
404
+ */
405
+ export function buildPresetItemsFromPairs(pairs: readonly PresetPair[]): readonly PresetItem[] {
406
+ const nullLabel = resolveNullLabelFromPairs(pairs) ?? "Infinite";
407
+ return pairs.map(([label, value]) => ({
408
+ label,
409
+ rawValue: value,
410
+ displayValue: serializePresetValue(value, nullLabel),
411
+ }));
412
+ }
413
+
414
+ /**
415
+ * Build the full `PresetItem[]` for a setting from its EFFECTIVE static presets. Thin wrapper
416
+ * over `buildPresetItemsFromPairs(resolveStaticPresets(setting))` — kept for its existing tested
417
+ * signature. Resolver-function sources yield `[]` here (resolved by the UI at open).
418
+ */
419
+ export function buildPresetItems(setting: SettingSchema): readonly PresetItem[] {
420
+ return buildPresetItemsFromPairs(resolveStaticPresets(setting));
421
+ }
@@ -0,0 +1,115 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
+
4
+ /**
5
+ * Generic settings persistence.
6
+ *
7
+ * Layered JSON file read/write with atomic writes.
8
+ * Uses schema for field names (no hardcoded per-field logic).
9
+ */
10
+
11
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { dirname, join } from "node:path";
14
+ import { normalizeFromSchema } from "./normalization.js";
15
+ import type { SettingsSchema } from "./schema.js";
16
+
17
+ /**
18
+ * Standard settings file paths for an extension named `name`.
19
+ *
20
+ * Returns the `globalPath` / `projectPath` pair every settings-ui consumer uses,
21
+ * so all extensions follow ONE layout (no per-consumer hand-rolled path logic):
22
+ * global: <globalDir | ~/.pi>/agent/<name>-settings.json
23
+ * project: <cwd>/.pi/<name>-settings.json
24
+ *
25
+ * Uses `os.homedir()` + `path.join` (cross-platform correct); consumers must NOT
26
+ * hand-roll these with env-var string concatenation.
27
+ */
28
+ export function settingsFilePaths(name: string): Pick<SettingsSchema, "globalPath" | "projectPath"> {
29
+ const filename = `${name}-settings.json`;
30
+ return {
31
+ globalPath: (globalDir?: string): string => join(globalDir ?? join(homedir(), ".pi"), "agent", filename),
32
+ projectPath: (cwd: string): string => join(cwd, ".pi", filename),
33
+ };
34
+ }
35
+
36
+ /** Read and parse a JSON file, returning null on any error. */
37
+ export function readJsonFile(filePath: string): Record<string, unknown> | null {
38
+ try {
39
+ if (!existsSync(filePath)) return null;
40
+ const raw = readFileSync(filePath, "utf-8");
41
+ return JSON.parse(raw) as Record<string, unknown>;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ /** Atomically write JSON to a file via temp file + rename. */
48
+ export function atomicWriteJson(filePath: string, data: unknown): void {
49
+ const dir = dirname(filePath);
50
+ if (!existsSync(dir)) {
51
+ mkdirSync(dir, { recursive: true });
52
+ }
53
+ const tmp = `${filePath}.tmp`;
54
+ writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, "utf-8");
55
+ renameSync(tmp, filePath);
56
+ }
57
+
58
+ /**
59
+ * Load settings from layered files (global + project override).
60
+ * Uses schema for field names — no per-field switch needed.
61
+ */
62
+ export function loadSettingsFromFiles(
63
+ cwd: string,
64
+ schema: SettingsSchema,
65
+ defaults: Record<string, unknown>,
66
+ globalDir: string | undefined,
67
+ ): Record<string, unknown> {
68
+ const globalPath = schema.globalPath(globalDir);
69
+ const projectPath = schema.projectPath(cwd);
70
+
71
+ const globalRaw = readJsonFile(globalPath);
72
+ const projectRaw = readJsonFile(projectPath);
73
+
74
+ // Start with defaults, layer global, then project
75
+ let settings = { ...defaults };
76
+
77
+ if (globalRaw) {
78
+ const normalized = normalizeFromSchema(globalRaw, schema);
79
+ // Merge all normalized global values
80
+ settings = { ...settings, ...normalized };
81
+ }
82
+
83
+ if (projectRaw) {
84
+ const normalized = normalizeFromSchema(projectRaw, schema);
85
+ // Only override keys that are explicitly present in the project file
86
+ for (const setting of schema.settings) {
87
+ const present = setting.id in projectRaw || setting.aliases?.some((a) => a in projectRaw);
88
+ if (present) {
89
+ settings[setting.id] = normalized[setting.id];
90
+ }
91
+ }
92
+ }
93
+
94
+ return settings;
95
+ }
96
+
97
+ /**
98
+ * Save settings to global file.
99
+ */
100
+ export function saveGlobalSettings(
101
+ settings: Record<string, unknown>,
102
+ schema: SettingsSchema,
103
+ globalDir: string | undefined,
104
+ ): void {
105
+ const filePath = schema.globalPath(globalDir);
106
+ atomicWriteJson(filePath, settings);
107
+ }
108
+
109
+ /**
110
+ * Save settings to project file.
111
+ */
112
+ export function saveProjectSettings(settings: Record<string, unknown>, cwd: string, schema: SettingsSchema): void {
113
+ const filePath = schema.projectPath(cwd);
114
+ atomicWriteJson(filePath, settings);
115
+ }
@@ -0,0 +1,66 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
+
4
+ /**
5
+ * UI-time batch resolution of function-`presets` sources.
6
+ *
7
+ * A setting whose effective `presets` is a function (the model resolver) is resolved
8
+ * once, at modal-open, into a map of pairs keyed by setting id. The map is threaded
9
+ * into `buildSchemaTabGroups` so the UI renders the resolved options. Resolvers take
10
+ * no arguments — they read captured context themselves. A resolver that throws or
11
+ * rejects degrades to an empty list, so one failing resolver never breaks the batch.
12
+ */
13
+
14
+ import { normalizePresetElements, resolveEffectivePresets } from "./normalization.js";
15
+ import type { PresetElement, PresetPair, SettingSchema, SettingsSchema } from "./schema.js";
16
+ import { getTypeDefinition } from "./type-definitions.js";
17
+
18
+ /**
19
+ * The pairs substituted when a resolver throws or rejects. A named const (not a bare
20
+ * `[]`) so the element type is carried: a bare `[]` in `.catch(() => [])` infers
21
+ * `never[]`, which won't widen to `PresetPair[]` for the resolved-entry tuple.
22
+ */
23
+ const EMPTY_PRESET_PAIRS: readonly PresetPair[] = [];
24
+
25
+ /** Pick the effective presets source (setting override OR type-def default) as a function. */
26
+ function getResolverFn(setting: SettingSchema): () => PresetElement[] | Promise<PresetElement[]> {
27
+ const src = setting.presets ?? getTypeDefinition(setting.type).presets;
28
+ return src as () => PresetElement[] | Promise<PresetElement[]>;
29
+ }
30
+
31
+ /** Result of resolving a schema's function-presets: the resolved pairs plus any that failed. */
32
+ export interface ResolvedPresets {
33
+ /** Setting id → resolved preset pairs (empty list for a setting whose resolver failed). */
34
+ pairs: Map<string, PresetPair[]>;
35
+ /** Ids of settings whose resolver threw or rejected. Caller surfaces these to the user. */
36
+ failedIds: readonly string[];
37
+ }
38
+
39
+ /**
40
+ * Resolve every function-`presets` setting in the schema.
41
+ *
42
+ * Static-array presets are ignored here (they stay WeakMap-cached in the tab builder);
43
+ * only function sources are resolved. Each resolver is invoked with no arguments and
44
+ * guarded so a synchronous throw or an async rejection becomes an empty pairs list —
45
+ * one failing resolver never breaks the batch. Failing setting ids are collected in
46
+ * `failedIds` so the caller can warn the user (the modal still opens with defaults).
47
+ */
48
+ export async function resolveFunctionPresets(schema: SettingsSchema): Promise<ResolvedPresets> {
49
+ const resolverSettings = schema.settings.filter((s) => resolveEffectivePresets(s).isFunction);
50
+ const failedIds: string[] = [];
51
+ const entries = await Promise.all(
52
+ resolverSettings.map((s) =>
53
+ Promise.resolve()
54
+ .then(async () => normalizePresetElements(await getResolverFn(s)(), s))
55
+ .catch(() => {
56
+ // Degrade to an empty list so the batch (and the modal) survive — covers both a resolver
57
+ // that throws/rejects AND a bad bare string in its result (normalizePresetElements throws).
58
+ // Record the id so the caller can tell the user this setting's options could not load.
59
+ failedIds.push(s.id);
60
+ return EMPTY_PRESET_PAIRS;
61
+ })
62
+ .then((pairs): readonly [string, PresetPair[]] => [s.id, [...pairs]]),
63
+ ),
64
+ );
65
+ return { pairs: new Map(entries), failedIds };
66
+ }