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
This commit is contained in:
Rongsong Shen 2026-08-26 13:48:27 +00:00
parent f9e8d97eac
commit 9de4db95d3
No known key found for this signature in database
GPG key ID: 5BCDAAE0123E9FE2
10 changed files with 632 additions and 1 deletions

View file

@ -54,7 +54,7 @@ controller = executable('xce_controller',
link_args: wrap_link_args,
install: true)
xce_test_src = [ 'testsuite/test.c', 'testsuite/waldump.c', 'testsuite/walcompare.c', 'testsuite/snapverify.c', 'testsuite/snapdiff.c', 'testsuite/configcompare.c', 'testsuite/resval_typeresolve.c']
xce_test_src = [ 'testsuite/test.c', 'testsuite/waldump.c', 'testsuite/walcompare.c', 'testsuite/snapverify.c', 'testsuite/snapdiff.c', 'testsuite/configcompare.c', 'testsuite/resval_typeresolve.c', 'testsuite/rcfile.c']
# BSD-licensed readline replacement (scheduler-core spec, task 12.1 review
# finding): GNU readline is GPL; xce_test's REPL only needs readline()/
@ -165,6 +165,8 @@ controller_unit_test_src = [
'testsuite/snapdiff.c',
'testsuite/test_configcompare.c',
'testsuite/configcompare.c',
'testsuite/test_rcfile.c',
'testsuite/rcfile.c',
'testsuite/test_persist_snapshot_entities.c',
'testsuite/test_apply_seam.c',
'testsuite/test_apply_committed_entry.c',

View file

@ -70,6 +70,7 @@ extern void test_walcompare(void);
extern void test_snapverify(void);
extern void test_snapdiff(void);
extern void test_configcompare(void);
extern void test_rcfile(void);
extern void test_drive_async_config(void);
extern void test_slowlane(void);
extern void test_commit_admission_pool(void);
@ -247,6 +248,7 @@ int main(int argc, char* argv[])
TEST(test_snapverify);
TEST(test_snapdiff);
TEST(test_configcompare);
TEST(test_rcfile);
TEST(test_drive_async_config);
TEST(test_slowlane);
TEST(test_commit_admission_pool);

View file

@ -0,0 +1,38 @@
#define _GNU_SOURCE
#include "rcfile.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* Suffixes appended to the resolved base directory to form the rc-file
* path -- kept as two separate constants (rather than one shared tail)
* since the XDG_CONFIG_HOME branch is already rooted at a "config home"
* directory while the HOME fallback still needs the "/.config" segment. */
#define RCFILE_XDG_SUFFIX "/xce_test/rc"
#define RCFILE_HOME_SUFFIX "/.config/xce_test/rc"
char* rcfile_resolve_path(void)
{
const char* xdg_config_home = getenv("XDG_CONFIG_HOME");
if (xdg_config_home && *xdg_config_home) {
size_t len = strlen(xdg_config_home) + strlen(RCFILE_XDG_SUFFIX) + 1;
char* path = (char*)malloc(len);
if (!path)
return NULL;
snprintf(path, len, "%s%s", xdg_config_home, RCFILE_XDG_SUFFIX);
return path;
}
const char* home = getenv("HOME");
if (home && *home) {
size_t len = strlen(home) + strlen(RCFILE_HOME_SUFFIX) + 1;
char* path = (char*)malloc(len);
if (!path)
return NULL;
snprintf(path, len, "%s%s", home, RCFILE_HOME_SUFFIX);
return path;
}
return NULL;
}

View file

@ -0,0 +1,20 @@
#pragma once
/*
* rcfile.h -- xce_test startup rc-file path resolution (xce-test-rc-file
* spec, task 1.1).
*
* A standalone, dependency-light module following the same pattern as
* configcompare.c/h -- no dependency on test.c internals, linked into both
* the xce_test executable and controller_unit_test.
*
* This task implements path resolution only (rcfile_resolve_path()).
* Line-loading (rcfile_load()) is a later task in the same spec and will
* extend this file, not replace it.
*/
/* 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" when HOME is set and non-empty. Returns
* a heap string the caller must free(), or NULL if neither variable yields
* a usable base directory. */
char* rcfile_resolve_path(void);

View file

@ -0,0 +1,188 @@
#define _GNU_SOURCE
#include <stdlib.h>
#include <string.h>
#include <unity/unity.h>
#include "rcfile.h"
/* Saved prior state of one environment variable, captured before a test
* case mutates it. Distinguishes "was unset" (getenv() returned NULL) from
* "was set to X" (including X == "") so restoration is exact -- required
* because controller_unit_test links 80+ test_*.c files into ONE process
* running sequentially, and a leaked HOME/XDG_CONFIG_HOME value would
* silently affect every test that runs after this file. */
typedef struct {
int was_set; /* 1 if getenv() returned non-NULL before mutation */
char* value; /* heap copy of the prior value; valid only if was_set */
} rcfile_envsave_t;
static rcfile_envsave_t env_save(const char* name)
{
rcfile_envsave_t s;
const char* cur = getenv(name);
if (cur) {
s.was_set = 1;
s.value = strdup(cur);
TEST_ASSERT_NOT_NULL(s.value);
} else {
s.was_set = 0;
s.value = NULL;
}
return s;
}
/* Restores `name` to exactly the state `s` captured, then frees `s`'s heap
* copy. Safe to call even if the test already unset/reset the variable
* itself -- always drives it back to the captured prior state. */
static void env_restore(const char* name, rcfile_envsave_t* s)
{
if (s->was_set) {
TEST_ASSERT_EQUAL_INT(0, setenv(name, s->value, 1));
free(s->value);
} else {
TEST_ASSERT_EQUAL_INT(0, unsetenv(name));
}
s->value = NULL;
}
static void test_resolve_uses_xdg_config_home_when_set_and_nonempty(void)
{
rcfile_envsave_t save_xdg = env_save("XDG_CONFIG_HOME");
rcfile_envsave_t save_home = env_save("HOME");
TEST_ASSERT_EQUAL_INT(
0, setenv("XDG_CONFIG_HOME", "/tmp/xce_rcfile_test_xdg", 1));
/* HOME is deliberately set to something recognizably different, so a
* pass here proves HOME was NOT consulted, not just that the result
* happens to match by coincidence. */
TEST_ASSERT_EQUAL_INT(
0, setenv("HOME", "/tmp/xce_rcfile_test_home_unused", 1));
char* path = rcfile_resolve_path();
TEST_ASSERT_NOT_NULL(path);
TEST_ASSERT_EQUAL_STRING("/tmp/xce_rcfile_test_xdg/xce_test/rc", path);
free(path);
env_restore("HOME", &save_home);
env_restore("XDG_CONFIG_HOME", &save_xdg);
}
static void test_resolve_falls_back_to_home_when_xdg_unset(void)
{
rcfile_envsave_t save_xdg = env_save("XDG_CONFIG_HOME");
rcfile_envsave_t save_home = env_save("HOME");
TEST_ASSERT_EQUAL_INT(0, unsetenv("XDG_CONFIG_HOME"));
TEST_ASSERT_EQUAL_INT(0, setenv("HOME", "/tmp/xce_rcfile_test_home", 1));
char* path = rcfile_resolve_path();
TEST_ASSERT_NOT_NULL(path);
TEST_ASSERT_EQUAL_STRING("/tmp/xce_rcfile_test_home/.config/xce_test/rc",
path);
free(path);
env_restore("HOME", &save_home);
env_restore("XDG_CONFIG_HOME", &save_xdg);
}
static void test_resolve_falls_back_to_home_when_xdg_empty(void)
{
rcfile_envsave_t save_xdg = env_save("XDG_CONFIG_HOME");
rcfile_envsave_t save_home = env_save("HOME");
TEST_ASSERT_EQUAL_INT(0, setenv("XDG_CONFIG_HOME", "", 1));
TEST_ASSERT_EQUAL_INT(
0, setenv("HOME", "/tmp/xce_rcfile_test_home2", 1));
char* path = rcfile_resolve_path();
TEST_ASSERT_NOT_NULL(path);
TEST_ASSERT_EQUAL_STRING("/tmp/xce_rcfile_test_home2/.config/xce_test/rc",
path);
free(path);
env_restore("HOME", &save_home);
env_restore("XDG_CONFIG_HOME", &save_xdg);
}
static void test_resolve_returns_null_when_neither_var_usable_both_unset(void)
{
rcfile_envsave_t save_xdg = env_save("XDG_CONFIG_HOME");
rcfile_envsave_t save_home = env_save("HOME");
TEST_ASSERT_EQUAL_INT(0, unsetenv("XDG_CONFIG_HOME"));
TEST_ASSERT_EQUAL_INT(0, unsetenv("HOME"));
char* path = rcfile_resolve_path();
TEST_ASSERT_NULL(path);
env_restore("HOME", &save_home);
env_restore("XDG_CONFIG_HOME", &save_xdg);
}
static void test_resolve_returns_null_when_neither_var_usable_both_empty(void)
{
rcfile_envsave_t save_xdg = env_save("XDG_CONFIG_HOME");
rcfile_envsave_t save_home = env_save("HOME");
TEST_ASSERT_EQUAL_INT(0, setenv("XDG_CONFIG_HOME", "", 1));
TEST_ASSERT_EQUAL_INT(0, setenv("HOME", "", 1));
char* path = rcfile_resolve_path();
TEST_ASSERT_NULL(path);
env_restore("HOME", &save_home);
env_restore("XDG_CONFIG_HOME", &save_xdg);
}
/* Proves the save/restore mechanism itself round-trips exactly, for all
* three prior-state shapes (unset / empty / a real value), regardless of
* whatever ambient state this test happened to start with -- this is what
* guarantees a test run AFTER this file never observes state leaked by it,
* since there is no later test in this same file to leak into. */
static void test_env_save_restore_roundtrips_all_three_prior_states(void)
{
const char* name = "XDG_CONFIG_HOME";
rcfile_envsave_t real_prior = env_save(name);
/* Case 1: prior state was unset. */
TEST_ASSERT_EQUAL_INT(0, unsetenv(name));
rcfile_envsave_t s1 = env_save(name);
TEST_ASSERT_EQUAL_INT(0, s1.was_set);
TEST_ASSERT_EQUAL_INT(0, setenv(name, "/tmp/mutated_during_case1", 1));
env_restore(name, &s1);
TEST_ASSERT_NULL(getenv(name));
/* Case 2: prior state was set to the empty string. */
TEST_ASSERT_EQUAL_INT(0, setenv(name, "", 1));
rcfile_envsave_t s2 = env_save(name);
TEST_ASSERT_EQUAL_INT(1, s2.was_set);
TEST_ASSERT_EQUAL_STRING("", s2.value);
TEST_ASSERT_EQUAL_INT(0, setenv(name, "/tmp/mutated_during_case2", 1));
env_restore(name, &s2);
TEST_ASSERT_NOT_NULL(getenv(name));
TEST_ASSERT_EQUAL_STRING("", getenv(name));
/* Case 3: prior state was set to a real value. */
TEST_ASSERT_EQUAL_INT(0, setenv(name, "/tmp/xce_rcfile_prior_value", 1));
rcfile_envsave_t s3 = env_save(name);
TEST_ASSERT_EQUAL_INT(1, s3.was_set);
TEST_ASSERT_EQUAL_STRING("/tmp/xce_rcfile_prior_value", s3.value);
TEST_ASSERT_EQUAL_INT(0, unsetenv(name));
env_restore(name, &s3);
TEST_ASSERT_NOT_NULL(getenv(name));
TEST_ASSERT_EQUAL_STRING("/tmp/xce_rcfile_prior_value", getenv(name));
env_restore(name, &real_prior);
}
void test_rcfile(void)
{
UnitySetTestFile(__FILE__);
RUN_TEST(test_resolve_uses_xdg_config_home_when_set_and_nonempty);
RUN_TEST(test_resolve_falls_back_to_home_when_xdg_unset);
RUN_TEST(test_resolve_falls_back_to_home_when_xdg_empty);
RUN_TEST(test_resolve_returns_null_when_neither_var_usable_both_unset);
RUN_TEST(test_resolve_returns_null_when_neither_var_usable_both_empty);
RUN_TEST(test_env_save_restore_roundtrips_all_three_prior_states);
}