myxce/.kiro/specs/xce-test-rc-file/design.md
Rongsong Shen 9de4db95d3
feat(xce-test-rc-file): task 1.1 rc-file path resolution with XDG precedence
Adds rcfile_resolve_path() (rcfile.c/rcfile.h): resolves $XDG_CONFIG_HOME/xce_test/rc
when set and non-empty, else $HOME/.config/xce_test/rc, else NULL. Standalone
libc-only module mirroring the configcompare.c pattern; wired into xce_test_src
and controller_unit_test_src, registered in controller_test.c's Unity runner.

6 unit tests in test_rcfile.c cover all XDG/HOME combinations plus a dedicated
env-var save/restore isolation test, addressing the cross-test leak risk found
during design review (controller_unit_test runs 80+ test files in one process).

Adds the xce-test-rc-file spec (requirements/design/tasks/research), including
design fixes from validation: getline()-based line reading (task 1.2, avoids
truncating long rc-file lines) and the env-isolation requirement above.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgYRU3zDTqNGV5Jk8aiKLg
2026-08-26 13:48:27 +00:00

204 lines
16 KiB
Markdown

# Design Document
## Overview
**Purpose**: This feature adds a startup rc file to `xce_test`, `src/controller/testsuite/test.c`'s REPL/scripting client, so a user's usual settings (`set masters ...`, `set secret ...`) and starting context are applied automatically instead of retyped every session.
**Users**: Engineers driving controller/worker/scheduler/HA soak and E2E testing through `xce_test`, both interactively and via piped scripts.
**Impact**: `xce_test` gains one new startup step (rc-file discovery and loading, before its read loop begins) and one new flag (`--norc`). No existing command, output format, or scripting primitive changes.
### Goals
- Auto-load and execute an rc file at a fixed, XDG-compliant path before `xce_test`'s interactive/scripted read loop starts, for both session kinds.
- Let `--norc` skip this entirely for a given invocation.
- Make a failing rc-file command behave exactly like a failing regular command — no new failure-handling mechanism.
### Non-Goals
- A `--rcfile <path>` custom-location override (deferred; orthogonal addition if wanted later).
- Any new scripting syntax/primitive beyond what `execute_line`/`preprocess_and_run` already supports.
- A per-project/cwd rc file, or a legacy dotfile fallback (e.g. `~/.xce_testrc`) — there is no prior rc-file convention to preserve.
- `xce_test` creating, writing, or scaffolding the rc file or its directory.
- Fixing `xce_test`'s general `-h`/error-stream/flag-validation issues — those are owned by the separate `xce-test-cli-ux` spec.
## Boundary Commitments
### This Spec Owns
- Resolving the rc-file path per the XDG Base Directory Specification's own defined precedence/fallback.
- Reading the rc file (if present) and feeding each line into the existing command-execution path before the read loop starts.
- The `--norc` flag and its `argp`/help wiring.
- Non-fatal handling of an unreadable (but present) rc file.
### Out of Boundary
- The behavior of any individual `xce_test` command once invoked (unchanged — commands run exactly as they do today, whether typed, piped, or rc-sourced).
- `preprocess_and_run`'s/`execute_line`'s abort-on-failure, continue-on-error, auto-variable, or exit-code-verdict logic (owned by `xce-test-e2e-primitives`; this spec only calls into it).
- `xce_test`'s process-level `--help`/`-h` completeness (owned by `xce-test-cli-ux`); this spec only guarantees `--norc` is documented via the existing `--help`/`-?` mechanism.
### Allowed Dependencies
- `preprocess_and_run` (`test.c:955`) as the per-line runner — called by function pointer, not reimplemented.
- Standard C library only (`getenv`, `fopen`/`getline`/`fclose`, `strdup`/`free`) — no new third-party dependency.
- `argp` (already a dependency of `xce_test`) for the `--norc` flag.
### Revalidation Triggers
- A change to `preprocess_and_run`'s return-code contract (0 = continue, nonzero = abort) — `rcfile_load`'s early-stop logic depends on it unchanged.
- A change to how/when `g_interactive`/`g_continue_on_error` are computed in `main()` — the rc-file load call must stay after that computation and before the first read-loop iteration.
- Introduction of a `--rcfile <path>` override or a legacy-dotfile fallback in a future spec — must compose with, not replace, this path-resolution contract.
## Architecture
### Existing Architecture Analysis
`xce_test`'s `testsuite/` directory already has an established pattern for a small, dependency-free capability: a standalone `.c`/`.h` module (e.g. `waldump.c`/`waldump.h`, `configcompare.c`/`configcompare.h`) linked into both the `xce_test` executable (`meson.build:57`) and, paired with its own `test_<name>.c`, into `controller_unit_test` (e.g. `meson.build:166-167`). `test.c` itself only wires a thin `cmd_*` wrapper or, in this feature's case, a call from `main()`. This feature follows that exact pattern rather than growing `main()`/`test.c` inline.
### Architecture Pattern & Boundary Map
```mermaid
flowchart TD
Start[main starts] --> ParseArgs[argp_parse fills norc flag]
ParseArgs --> ComputeMode[Compute g_interactive and g_continue_on_error]
ComputeMode --> NorcCheck{norc flag set}
NorcCheck -- yes --> ReadLoop[Enter while not done read loop]
NorcCheck -- no --> Resolve[rcfile_resolve_path]
Resolve --> HasPath{Path resolved}
HasPath -- no --> ReadLoop
HasPath -- yes --> Load[rcfile_load path preprocess_and_run]
Load --> Stopped{Runner returned nonzero}
Stopped -- yes --> Exit[Return session_failed exit code]
Stopped -- no --> ReadLoop
```
**Architecture Integration**:
- Selected pattern: standalone module (`rcfile.c`/`rcfile.h`) + one call site in `test.c`'s `main()`.
- Domain/feature boundaries: `rcfile.c` owns path resolution and line iteration only; it has no knowledge of commands, contexts, or client state — it receives a runner callback and calls it once per line.
- Existing patterns preserved: `configcompare.c`-style module pairing in both build targets; `preprocess_and_run`'s existing per-line contract untouched.
- New components rationale: `rcfile.c`/`rcfile.h` (new capability, independently testable); one new `argp` option and one new call site in `test.c` (integration only).
- Steering compliance: one domain per file (`structure.md`); test-first, `testsuite/test_<unit>.c` naming (`testing.md`).
### Technology Stack
| Layer | Choice / Version | Role in Feature | Notes |
|-------|------------------|-----------------|-------|
| CLI (C) | `argp` (existing dependency) | Declares `--norc` | Same mechanism as the existing `-v`/`--verbose` option (`test.c:674-677`) |
| Runtime | C standard library (`getenv`, `stdio.h`, POSIX `getline`) | Path resolution + file reading | No new dependency; `getline` avoids a fixed-size line buffer |
## File Structure Plan
### Directory Structure
```
src/controller/testsuite/
├── test.c # Modified: --norc option, call site in main()
├── rcfile.c # New: path resolution + line-loading loop
├── rcfile.h # New: public interface for rcfile.c
└── test_rcfile.c # New: Unity unit tests for rcfile.c (fake runner)
```
### Modified Files
- `src/controller/testsuite/test.c` — add `int norc` to `struct arguments`; add a `norc` entry to `options[]`; add a symbolic `OPT_NORC` key constant and its case in `parse_opt`; in `main()`, after `g_continue_on_error` is computed and before the `while (!done)` loop, call `rcfile_resolve_path()`/`rcfile_load()` when `arguments.norc` is not set, returning early with the existing `g_session_failed ? 1 : 0` convention if `rcfile_load` signals abort.
- `src/controller/meson.build` — add `'testsuite/rcfile.c'` to `xce_test_src` (`meson.build:57`); add `'testsuite/test_rcfile.c'` and `'testsuite/rcfile.c'` to `controller_unit_test_src`, following the `test_configcompare.c`/`configcompare.c` pairing already there (`meson.build:166-167`).
### New Files
- `src/controller/testsuite/rcfile.h` — declares `rcfile_resolve_path(void)` and `rcfile_load(const char* path, int (*runner)(char*))`.
- `src/controller/testsuite/rcfile.c` — implements both functions; no dependency beyond libc.
- `src/controller/testsuite/test_rcfile.c` — Unity tests using a fake `int (*)(char*)` runner (records calls, returns a scripted result) plus `setenv`/`unsetenv` to drive `rcfile_resolve_path`.
## Requirements Traceability
| Requirement | Summary | Components | Interfaces | Flows |
|-------------|---------|------------|------------|-------|
| 1.1 | Resolve rc path with XDG precedence | `rcfile.c` | `rcfile_resolve_path` | Resolve step |
| 1.2 | Execute rc lines through the existing command path | `rcfile.c`, `test.c::main` | `rcfile_load(path, preprocess_and_run)` | Load step |
| 1.3 | Missing rc file is silent, not an error | `rcfile.c` | `rcfile_load` (ENOENT branch) | HasPath=no / open ENOENT |
| 1.4 | Load before the first read-loop line, both session kinds | `test.c::main` | call site placement | NorcCheck → Resolve → Load → ReadLoop |
| 1.5 | Context/settings carry into the read loop | `test.c` (existing `active_ctx`/`settings` globals) | none new | implied by shared global state |
| 2.1 | `--norc` skips loading entirely | `test.c::main`, `parse_opt` | `arguments.norc` | NorcCheck=yes |
| 2.2 | `--norc` documented in `--help` | `test.c::options[]` | `argp` doc string | n/a |
| 2.3 | Default (no `--norc`) still loads | `test.c::main` | NorcCheck=no branch | Resolve → Load |
| 3.1 | Rc failure reported like any command failure | `preprocess_and_run` (reused, unmodified) | `rcfile_load`'s runner callback | Load step |
| 3.2 | Interactive: continue past a failing rc line | `preprocess_and_run` (existing `g_continue_on_error` branch) | runner return 0 on recorded failure | Load loop continues |
| 3.3 | Scripted: abort before piped script runs | `preprocess_and_run` (existing abort branch), `rcfile_load` | runner return nonzero → early stop | Stopped=yes → Exit |
| 3.4 | Same session-verdict/exit-code accounting | `preprocess_and_run` (existing `g_last_status`/`g_session_failed`) | none new | n/a |
## Components and Interfaces
| Component | Domain/Layer | Intent | Req Coverage | Key Dependencies (P0/P1) | Contracts |
|-----------|--------------|--------|---------------|---------------------------|-----------|
| `rcfile.c`/`rcfile.h` | CLI startup | Resolve rc path; load and run its lines via a caller-supplied runner | 1.1, 1.2, 1.3 | none (P0: libc only) | Service |
| `test.c::main` integration | CLI startup | Wire `--norc`, call `rcfile.c`, honor its abort signal | 1.4, 1.5, 2.1, 2.3 | `rcfile.c` (P0), `preprocess_and_run` (P0) | Service |
| `test.c::options[]`/`parse_opt` | CLI arg parsing | Declare and parse `--norc` | 2.1, 2.2 | `argp` (P0) | Service |
### CLI Startup
#### rcfile module
| Field | Detail |
|-------|--------|
| Intent | Resolve the rc-file path and, if present, execute its lines through a caller-supplied runner |
| Requirements | 1.1, 1.2, 1.3 |
**Responsibilities & Constraints**
- Owns path resolution (`$XDG_CONFIG_HOME` / `$HOME/.config` precedence) and line iteration only.
- Has no knowledge of `xce_test` commands, contexts, or client state — the runner is an opaque callback.
- Never writes to the rc file or creates its directory.
**Dependencies**
- Outbound: caller-supplied runner function pointer (P0) — invoked once per line, trailing newline stripped.
- External: libc `getenv`/`fopen`/`getline` (P0).
**Contracts**: Service [x] / API [ ] / Event [ ] / Batch [ ] / State [ ]
##### Service Interface
```c
/* Resolves the rc-file path per the XDG Base Directory Specification:
* "$XDG_CONFIG_HOME/xce_test/rc" when XDG_CONFIG_HOME is set and non-empty,
* else "$HOME/.config/xce_test/rc". Returns a heap string the caller must
* free(), or NULL if neither variable yields a usable base directory. */
char* rcfile_resolve_path(void);
/* Opens `path` and calls `runner(line)` once per line (trailing newline
* stripped), stopping early if `runner` returns nonzero. Lines are read via
* getline() with no length cap -- a line is never truncated or split
* regardless of length.
*
* Returns:
* 0 - path is NULL, the file does not exist (ENOENT),
* the file could not be opened for another reason
* (a warning is printed to stderr; non-fatal), or
* every line ran and every runner call returned 0.
* nonzero (== runner's) - the runner requested an abort; no further lines
* are read. */
int rcfile_load(const char* path, int (*runner)(char* line));
```
- Preconditions: `runner` is non-NULL when `path` is non-NULL.
- Postconditions: the file handle is always closed before returning.
- Invariants: lines are run in file order; iteration stops at the first nonzero runner result.
**Implementation Notes**
- Integration: `main()` calls `rcfile_resolve_path()`, and — if non-NULL — `rcfile_load(path, preprocess_and_run)`, then `free()`s the path. A nonzero `rcfile_load` result short-circuits straight to `return g_session_failed ? 1 : 0;`, skipping the `while (!done)` loop entirely (Requirement 3.3).
- Line reading: `rcfile_load` reads each line with POSIX `getline()` (a dynamically-growing heap buffer, freed once at loop exit), not a fixed-size `fgets()` buffer. `rcfile.c` deliberately has no dependency on `test.c`'s `MAX_LINE_LEN`, so a fixed buffer sized independently of it risks silently truncating (or splitting into two runner calls) a long line — e.g. a multi-peer `set masters host1:port,host2:port,...` line — producing a mangled command with no error reported. `getline()` has no such cap and keeps `rcfile.c` dependency-free (libc only), consistent with its stated architecture.
- Validation: `test_rcfile.c` uses a fake runner to prove the stop-on-nonzero and ENOENT-is-silent contracts without depending on `preprocess_and_run` or any client/network state, plus a case with a line well beyond a typical small fixed-buffer size (e.g. >4KB) to prove no truncation/splitting occurs.
- Risks: none identified — the module has no I/O beyond one file read and no shared mutable state.
## Error Handling
### Error Strategy
- **Rc file absent (`ENOENT`)**: silent, non-fatal — `rcfile_load` returns 0 (Requirement 1.3).
- **Rc file present but unreadable** (permission denied, is a directory, etc.): non-fatal — `rcfile_load` prints one `stderr` warning naming the path and `strerror(errno)`, then returns 0 so startup proceeds normally.
- **A line in the rc file fails**: handled entirely by the reused `preprocess_and_run` — its existing error output, `g_last_status`/`g_session_failed` accounting, and mode-dependent abort/continue decision apply unchanged (Requirement 3).
- **Neither `$XDG_CONFIG_HOME` nor `$HOME` resolves a base directory**: `rcfile_resolve_path` returns `NULL`; `main()` treats this identically to "no rc file found" — no error reported.
## Testing Strategy
### Unit Tests (`test_rcfile.c`, added to `controller_unit_test`)
- **Test isolation**: `controller_unit_test` links 80+ `test_*.c` files into one process running sequentially, so any `setenv`/`unsetenv` on `$HOME`/`$XDG_CONFIG_HOME` here must not leak into tests that run after it. Before each test case that touches either variable, capture the prior state with `getenv()` (distinguishing "was unset" from "was empty" — e.g. via a `char*` that is `NULL` vs a duplicated non-NULL string); after the test case, restore that exact prior state (`unsetenv()` if it had been unset, `setenv(..., 1)` with the saved value otherwise). Apply this consistently across every test below that sets either variable.
- `rcfile_resolve_path` returns `$XDG_CONFIG_HOME/xce_test/rc` when `XDG_CONFIG_HOME` is set and non-empty.
- `rcfile_resolve_path` returns `$HOME/.config/xce_test/rc` when `XDG_CONFIG_HOME` is unset or empty.
- `rcfile_resolve_path` returns `NULL` when neither `XDG_CONFIG_HOME` nor `HOME` is usable.
- `rcfile_load` returns 0 and never calls the runner when the path does not exist.
- `rcfile_load` calls the runner once per line, in order, with the trailing newline stripped.
- `rcfile_load` stops after a runner call that returns nonzero, propagates that value, and does not call the runner for remaining lines (proves Requirement 3.3's "no further input" contract at the module level).
- `rcfile_load` on a non-`ENOENT` open failure (e.g. path is a directory) prints a warning and returns 0 without invoking the runner.
### Integration Tests (new `testsuite/test_rcfile_integration.sh`, shell-driven against the built `xce_test` binary — mirrors the existing `clean_failover_preservation.sh` pattern)
- With `HOME`/`XDG_CONFIG_HOME` pointed at a temp dir containing an rc file with `set masters ...`, piping a script into `xce_test` shows the overridden setting took effect.
- `--norc` with the same rc file present leaves the default `masters` value untouched.
- A deliberately failing rc-file line (e.g. malformed `set masters`) in piped mode causes `xce_test` to exit nonzero before any of the piped script's own commands produce output.
- The same failing rc-file line in an interactive (TTY-simulated) session still reaches the normal prompt afterward.
- **Compatibility regression check**: with no rc file present (the state every existing caller runs in today), the `xce-test-ha` and `xce-test-resources` skills' existing soak/verification scripts — the known live consumers of `xce_test` — produce the same pass/fail result they did before this feature, proving Requirement 1.3's "no rc file → unchanged behavior" guarantee holds for real, pre-existing automation and not only for this feature's own new integration script.