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
204 lines
16 KiB
Markdown
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.
|