import type { ConfigCommandDeps } from "@/commands/config"; import { getConfigValue, parseConfigKey, parseConfigUpdate, setConfigOverrideValue, setConfigValue, } from "@/commands/config-model"; import { collectConfigValidationErrors, collectEffectiveConfigValidationErrors, formatConfigValidationMessage, getEffectiveConfigErrorHeader, loadDisplayLayers, loadExistingConfig, loadExistingEffectiveConfig, loadExistingRawConfig, loadExistingRawOverride, resolveLocalConfigPathOrThrow, } from "@/commands/config-runtime"; import { parseConfigOverrideWithDiagnostics } from "@/lib/config"; import { formatConfigLayersDisplay, formatConfigRawLayersDisplay, formatReadableConfigSection, } from "@/lib/config-display"; import type { Config } from "@/lib/types"; type ConfigScope = "global" | "local"; type ResolvedReadScope = "effective" | ConfigScope; interface ParsedScopedArgs { scope: ResolvedReadScope; positional: string[]; } interface ParsedShowArgs extends ParsedScopedArgs { json: boolean; verbose: boolean; } type LoadedEffectiveConfig = Awaited< ReturnType >; type ValidEffectiveConfig = LoadedEffectiveConfig & { config: Config }; const SHOW_USAGE = "Usage: rr config show [--local|--global] [--json] [--verbose]"; function shellQuote(value: string): string { return `'${value.replaceAll("'", `'"'"'`)}'`; } function formatValue(value: unknown): string { if (value === null) { return "null"; } return String(value); } function printValue(value: unknown, print: (value: string) => void): void { if (typeof value === "object" && value !== null) { print(JSON.stringify(value, null, 2)); return; } print(String(value)); } async function warnIfEffectiveConfigInvalid( deps: ConfigCommandDeps, remediation: string ): Promise { const effective = await deps.loadEffectiveConfigWithDiagnostics(deps.cwd()); const errors = collectEffectiveConfigValidationErrors(effective); if (!effective.config || errors.length > 0) { deps.log.warn( formatConfigValidationMessage( getEffectiveConfigErrorHeader(effective), errors.length > 0 ? errors : ["Configuration format is invalid."], remediation ) ); return null; } return { ...effective, config: effective.config }; } function parseScopedArgs(args: string[], defaultScope: ResolvedReadScope): ParsedScopedArgs { const positional: string[] = []; const state = { scope: defaultScope, sawLocal: false, sawGlobal: false }; for (const arg of args) { if (!parseScopeFlag(arg, state)) { positional.push(arg); } } return { scope: state.scope, positional }; } function parseScopeFlag( arg: string, state: { scope: ResolvedReadScope; sawLocal: boolean; sawGlobal: boolean } ): boolean { if (arg === "--local") { if (state.sawGlobal) { throw new Error("Cannot use --local and --global together."); } state.sawLocal = true; state.scope = "local"; return true; } if (arg === "--global") { if (state.sawLocal) { throw new Error("Cannot use --local and --global together."); } state.sawGlobal = true; state.scope = "global"; return true; } return false; } function parseShowArgs(args: string[]): ParsedShowArgs { const positional: string[] = []; const scopeState = { scope: "effective" as ResolvedReadScope, sawLocal: false, sawGlobal: false }; let json = false; let verbose = false; for (const arg of args) { if (parseScopeFlag(arg, scopeState)) { continue; } if (arg === "--json") { json = true; continue; } if (arg === "--verbose") { verbose = true; continue; } positional.push(arg); } return { scope: scopeState.scope, positional, json, verbose }; } export async function runShow(args: string[], deps: ConfigCommandDeps): Promise { const parsed = parseShowArgs(args); if (parsed.positional.length !== 0) { throw new Error(SHOW_USAGE); } if (parsed.scope === "effective") { const layers = await loadDisplayLayers(deps); if (parsed.json) { deps.print( formatConfigRawLayersDisplay(layers.effective, layers.globalConfig, layers.localConfig) ); return; } deps.note( formatConfigLayersDisplay(layers.effective, layers.globalConfig, layers.localConfig, { showMetadata: parsed.verbose, }), "Configuration" ); return; } if (parsed.scope === "global") { const path = deps.configPath; const config = await loadExistingRawConfig(path, deps); if (parsed.json) { deps.print(JSON.stringify(config, null, 2)); return; } deps.note( formatReadableConfigSection({ title: "Global config", path, config, mode: "full", showMetadata: parsed.verbose, }), "Configuration" ); return; } const path = await resolveLocalConfigPathOrThrow(deps); const config = await loadExistingRawOverride(path, deps); if (parsed.json) { deps.print(JSON.stringify(config, null, 2)); return; } deps.note( formatReadableConfigSection({ title: "Repo-local overrides", path, config, mode: "override", showMetadata: parsed.verbose, }), "Configuration" ); } export async function runGet(args: string[], deps: ConfigCommandDeps): Promise { const parsed = parseScopedArgs(args, "effective"); if (parsed.positional.length !== 1) { throw new Error("Usage: rr config get [--local|--global] "); } const key = parseConfigKey(parsed.positional[0] as string); const config = parsed.scope === "effective" ? await loadExistingEffectiveConfig(deps) : parsed.scope === "global" ? await loadExistingRawConfig(deps.configPath, deps) : await loadExistingRawOverride(await resolveLocalConfigPathOrThrow(deps), deps); const value = getConfigValue(config, key); if (value === undefined) { throw new Error(`Key "${key}" is not set in the current configuration.`); } printValue(value, deps.print); } export async function runSet(args: string[], deps: ConfigCommandDeps): Promise { const parsed = parseScopedArgs(args, "global"); if (parsed.positional.length !== 2) { throw new Error("Usage: rr config set [--local|--global] "); } const key = parseConfigKey(parsed.positional[0] as string); const rawValue = parsed.positional[1] as string; const parsedUpdate = parseConfigUpdate(key, rawValue); const parsedValue = parsedUpdate.value; const localPath = parsed.scope === "local" ? await resolveLocalConfigPathOrThrow(deps) : null; if (localPath !== null) { let current: Config | null = null; try { current = await loadExistingEffectiveConfig(deps); } catch { const currentOverride = await loadExistingRawOverride(localPath, deps); const updatedOverride = setConfigOverrideValue(currentOverride, parsedUpdate); const normalizedOverride = parseConfigOverrideWithDiagnostics(updatedOverride as unknown); if (!normalizedOverride.config || normalizedOverride.errors.length > 0) { throw new Error( formatConfigValidationMessage( "Updated repo-local configuration is invalid.", normalizedOverride.errors.length > 0 ? normalizedOverride.errors : ["Configuration format is invalid."] ) ); } await deps.saveConfigOverride(normalizedOverride.config, localPath); deps.log.success(`Updated "${key}" to ${formatValue(parsedValue)}.`); return; } const updated = setConfigValue(current, key, parsedValue); const normalized = deps.parseConfigWithDiagnostics(updated as unknown); const validationErrors = collectConfigValidationErrors(normalized.config, normalized.errors); if (!normalized.config || validationErrors.length > 0) { throw new Error( formatConfigValidationMessage( "Updated configuration is invalid.", validationErrors.length > 0 ? validationErrors : ["Configuration format is invalid."] ) ); } const globalBase = await deps.loadConfig(deps.configPath); await deps.saveConfigOverride( deps.buildConfigOverride(globalBase, normalized.config), localPath ); deps.log.success(`Updated "${key}" to ${formatValue(parsedValue)}.`); return; } const current = await loadExistingConfig(deps); const updated = setConfigValue(current, key, parsedValue); const normalized = deps.parseConfigWithDiagnostics(updated as unknown); const validationErrors = collectConfigValidationErrors(normalized.config, normalized.errors); if (!normalized.config || validationErrors.length > 0) { throw new Error( formatConfigValidationMessage( "Updated configuration is invalid.", validationErrors.length > 0 ? validationErrors : ["Configuration format is invalid."] ) ); } await deps.saveConfig(normalized.config); deps.log.success(`Updated "${key}" to ${formatValue(parsedValue)}.`); await warnIfEffectiveConfigInvalid( deps, "Fix the repo-local override or restore compatible global values, then try again." ); } export async function runEdit(args: string[], deps: ConfigCommandDeps): Promise { const parsed = parseScopedArgs(args, "global"); if (parsed.positional.length !== 0) { throw new Error("Usage: rr config edit [--local|--global]"); } const editor = deps.env.EDITOR?.trim(); if (!editor) { throw new Error('EDITOR is not set. Set $EDITOR (for example: export EDITOR="vim").'); } const path = parsed.scope === "local" ? await resolveLocalConfigPathOrThrow(deps) : deps.configPath; const dirSeparatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); const dir = dirSeparatorIndex >= 0 ? path.substring(0, dirSeparatorIndex) : ""; await deps.ensureConfigDir(dir); const shell = deps.env.SHELL?.trim() || "sh"; const command = `exec $EDITOR ${shellQuote(path)}`; const proc = deps.spawn([shell, "-lc", command], { stdin: "inherit", stdout: "inherit", stderr: "inherit", }); const exitCode = await proc.exited; if (exitCode !== 0) { throw new Error(`Editor exited with code ${exitCode}.`); } if (!(await deps.configExists(path))) { deps.log.warn(`No config file was saved at ${path}.`); return; } if (parsed.scope === "local") { const effective = await warnIfEffectiveConfigInvalid( deps, 'Run "rr init" and choose Repo-local config to regenerate the file, or fix it manually.' ); if (!effective) { return; } const globalBase = await deps.loadConfig(deps.configPath); await deps.saveConfigOverride(deps.buildConfigOverride(globalBase, effective.config), path); return; } const loaded = await deps.loadConfigWithDiagnostics(path); const errors = collectConfigValidationErrors(loaded.config, loaded.errors); if (!loaded.config || errors.length > 0) { deps.log.warn( formatConfigValidationMessage( `Invalid configuration: ${path}`, errors.length > 0 ? errors : ["Configuration format is invalid."], 'Run "rr init" to regenerate the file, or fix it manually.' ) ); return; } const effective = await warnIfEffectiveConfigInvalid( deps, "Fix the repo-local override or restore compatible global values, then try again." ); if (!effective) { return; } await deps.saveConfig(loaded.config, path); }