Move xce_test's 15 production sources + headers from src/controller/testsuite/ into a new src/harness/ component with its own meson.build, and strip every now-dangling controller-side reference (11 controller_unit_test_src entries, 4 standalone test-binary blocks, 11 controller_test.c extern/RUN_TEST lines) so the controller build is never left broken. Fixes a broken relative-symlink bridge (xce_test_controller_link) added to keep two controller-embedded tests (test_test_cli.c, test_xcetest_process_entry.c) finding the relocated xce_test binary until task 2.1 moves them too. Discovered and documented in design.md: four controller-domain regression tests call functions directly from four of xce_test's own files, requiring controller_unit_test to permanently recompile them from src/harness/ — a real dependency neither the original discovery nor design audit caught. controller_unit_test: 1228 Tests 0 Failures 0 Ignored (verified via the Unity binary's own printed summary line, not just meson's aggregate count). Task 1.1 of xce-harness-component. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RLSNJhNoULvVcUhXEFafQC
298 lines
9.7 KiB
C
298 lines
9.7 KiB
C
#define _GNU_SOURCE
|
|
#include "flagspec.h"
|
|
|
|
#include <errno.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
|
|
/* True iff `token` (a raw command-line argument, e.g. "-h", "--host", or
|
|
* -- defensively -- a dash-free bare name) identifies the flag described
|
|
* by `fs`. A "--name" token is matched against fs->long_opt; a "-X" token
|
|
* is matched against fs->short_opt (single character only, so "-xy" never
|
|
* falsely matches a single-letter short_opt). */
|
|
static int flag_token_matches(const char* token, const flagspec_t* fs)
|
|
{
|
|
if (!token || !fs)
|
|
return 0;
|
|
|
|
if (token[0] == '-' && token[1] == '-')
|
|
return fs->long_opt != NULL && strcmp(token + 2, fs->long_opt) == 0;
|
|
|
|
if (token[0] == '-' && token[1] != '\0')
|
|
return fs->short_opt != 0 && token[1] == fs->short_opt &&
|
|
token[2] == '\0';
|
|
|
|
/* No leading dash at all -- not how a real getopt_long() failure
|
|
* token looks, but handled so this helper never mis-signals on
|
|
* unexpected input from a caller that skips the usual
|
|
* argv[optind - 1] convention. */
|
|
if (fs->long_opt != NULL && strcmp(token, fs->long_opt) == 0)
|
|
return 1;
|
|
return fs->short_opt != 0 && token[0] == fs->short_opt &&
|
|
token[1] == '\0';
|
|
}
|
|
|
|
int flagspec_build_opts(const flagspec_t* spec, size_t n,
|
|
struct option* out_opts, char* out_optstring,
|
|
size_t out_optstring_cap)
|
|
{
|
|
if (!spec || !out_opts || !out_optstring || n == 0 ||
|
|
out_optstring_cap == 0)
|
|
return -EINVAL;
|
|
|
|
size_t str_idx = 0;
|
|
|
|
for (size_t i = 0; i < n; i++) {
|
|
const flagspec_t* fs = &spec[i];
|
|
|
|
if (!fs->long_opt || !*fs->long_opt)
|
|
return -EINVAL;
|
|
if (fs->has_arg != no_argument && fs->has_arg != required_argument)
|
|
return -EINVAL;
|
|
|
|
out_opts[i].name = fs->long_opt;
|
|
out_opts[i].has_arg = fs->has_arg;
|
|
out_opts[i].flag = NULL;
|
|
out_opts[i].val = fs->short_opt
|
|
? (int)fs->short_opt
|
|
: (int)(FLAGSPEC_LONG_ONLY_BASE + i);
|
|
|
|
if (fs->short_opt != 0) {
|
|
size_t need = (fs->has_arg == required_argument) ? 2 : 1;
|
|
/* Leave room for the final NUL written after the loop. */
|
|
if (str_idx + need >= out_optstring_cap)
|
|
return -ENOSPC;
|
|
out_optstring[str_idx++] = fs->short_opt;
|
|
if (fs->has_arg == required_argument)
|
|
out_optstring[str_idx++] = ':';
|
|
}
|
|
}
|
|
|
|
out_optstring[str_idx] = '\0';
|
|
|
|
/* Trailing all-zero sentinel getopt_long() requires to terminate the
|
|
* table; out_opts must have room for n+1 entries (precondition). */
|
|
out_opts[n].name = NULL;
|
|
out_opts[n].has_arg = 0;
|
|
out_opts[n].flag = NULL;
|
|
out_opts[n].val = 0;
|
|
|
|
return 0;
|
|
}
|
|
|
|
/* Levenshtein edit distance between two NUL-terminated strings -- the
|
|
* minimum number of single-character insertions/deletions/substitutions
|
|
* that turns `a` into `b`. Small and self-contained: research.md confirms
|
|
* no edit-distance utility exists anywhere in the codebase yet. Standard
|
|
* two-row rolling DP, O(len(a)*len(b)) time, O(min(len(a),len(b))+1)
|
|
* space -- more than fast enough for flag-name-sized strings.
|
|
*
|
|
* Returns (size_t)-1 on allocation failure, which callers must treat as
|
|
* "not a close match" rather than a false zero-distance/exact-match
|
|
* signal. */
|
|
static size_t levenshtein(const char* a, const char* b)
|
|
{
|
|
size_t la = strlen(a), lb = strlen(b);
|
|
|
|
/* Keep the rolling row sized to the SHORTER string. */
|
|
if (lb > la) {
|
|
const char* tmp = a;
|
|
a = b;
|
|
b = tmp;
|
|
size_t t = la;
|
|
la = lb;
|
|
lb = t;
|
|
}
|
|
|
|
size_t* row = calloc(lb + 1, sizeof(size_t));
|
|
if (!row)
|
|
return (size_t)-1;
|
|
|
|
for (size_t j = 0; j <= lb; j++)
|
|
row[j] = j;
|
|
|
|
for (size_t i = 1; i <= la; i++) {
|
|
size_t prev_diag = row[0];
|
|
row[0] = i;
|
|
for (size_t j = 1; j <= lb; j++) {
|
|
size_t saved = row[j];
|
|
size_t cost = (a[i - 1] == b[j - 1]) ? 0 : 1;
|
|
size_t del = row[j] + 1;
|
|
size_t ins = row[j - 1] + 1;
|
|
size_t sub = prev_diag + cost;
|
|
size_t best = del < ins ? del : ins;
|
|
if (sub < best)
|
|
best = sub;
|
|
row[j] = best;
|
|
prev_diag = saved;
|
|
}
|
|
}
|
|
|
|
size_t result = row[lb];
|
|
free(row);
|
|
return result;
|
|
}
|
|
|
|
/* True iff `candidate` (one flag's literal spelling, e.g. "-h" or
|
|
* "--host") "closely matches" `bad_flag` for typo-suggestion purposes
|
|
* (Requirement 10.1).
|
|
*
|
|
* Threshold, a deliberate design choice (design.md/research.md do not
|
|
* mandate a specific algorithm or cutoff): floor(len(candidate)/4),
|
|
* clamped to [1,3]. A fixed absolute threshold would be wrong at both
|
|
* ends -- distance 1 from a 2-character short flag ("-h" vs "-z") is a
|
|
* near-total rewrite, while distance 1 from an 8+ character long flag is
|
|
* a single typo -- so the tolerance scales with the spelling's own
|
|
* length instead. Exact matches (distance 0) are never "close" here:
|
|
* flagspec_reject() only calls this from the already-unrecognized branch,
|
|
* where an exact-spelling candidate would be a contradiction, and this
|
|
* keeps the helper correct standalone too. */
|
|
static int is_close_match(const char* bad_flag, const char* candidate)
|
|
{
|
|
size_t dist = levenshtein(bad_flag, candidate);
|
|
if (dist == 0 || dist == (size_t)-1)
|
|
return 0;
|
|
|
|
size_t len = strlen(candidate);
|
|
size_t threshold = len / 4;
|
|
if (threshold < 1)
|
|
threshold = 1;
|
|
if (threshold > 3)
|
|
threshold = 3;
|
|
|
|
return dist <= threshold;
|
|
}
|
|
|
|
/* Writes `fs`'s display label ("-h/--host" or, for a long-only flag,
|
|
* "--json") into `buf`. */
|
|
static void format_flag_label(char* buf, size_t cap, const flagspec_t* fs)
|
|
{
|
|
if (fs->short_opt)
|
|
snprintf(buf, cap, "-%c/--%s", fs->short_opt, fs->long_opt);
|
|
else
|
|
snprintf(buf, cap, "--%s", fs->long_opt);
|
|
}
|
|
|
|
/* Returns the single flagspec_t in `spec` whose short or long spelling
|
|
* closely matches `bad_flag`, or NULL when zero or more than one entry
|
|
* qualifies -- Requirement 10.1 only promises a guess when exactly one
|
|
* flag is a plausible intended target; an ambiguous typo (two or more
|
|
* equally-plausible candidates) gets no guess at all, the same as a typo
|
|
* that matches nothing (Requirement 10.2). */
|
|
static const flagspec_t* find_typo_suggestion(const flagspec_t* spec, size_t n,
|
|
const char* bad_flag)
|
|
{
|
|
if (!spec || !bad_flag || !*bad_flag)
|
|
return NULL;
|
|
|
|
const flagspec_t* found = NULL;
|
|
|
|
for (size_t i = 0; i < n; i++) {
|
|
const flagspec_t* fs = &spec[i];
|
|
int is_close = 0;
|
|
|
|
if (fs->short_opt) {
|
|
char short_form[3] = { '-', fs->short_opt, '\0' };
|
|
if (is_close_match(bad_flag, short_form))
|
|
is_close = 1;
|
|
}
|
|
if (!is_close && fs->long_opt && *fs->long_opt) {
|
|
char long_form[128];
|
|
snprintf(long_form, sizeof(long_form), "--%s", fs->long_opt);
|
|
if (is_close_match(bad_flag, long_form))
|
|
is_close = 1;
|
|
}
|
|
|
|
if (is_close) {
|
|
if (found)
|
|
return NULL; /* ambiguous -- more than one candidate */
|
|
found = fs;
|
|
}
|
|
}
|
|
|
|
return found;
|
|
}
|
|
|
|
int flagspec_reject(const char* cmd_name, const flagspec_t* spec, size_t n,
|
|
const char* bad_flag, const char* usage_text)
|
|
{
|
|
const char* display = bad_flag ? bad_flag : "?";
|
|
const char* who = cmd_name ? cmd_name : "?";
|
|
const flagspec_t* match = NULL;
|
|
|
|
if (spec) {
|
|
for (size_t i = 0; i < n; i++) {
|
|
if (flag_token_matches(bad_flag, &spec[i])) {
|
|
match = &spec[i];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (match && match->has_arg == required_argument) {
|
|
fprintf(stderr, "xce_test: %s: flag '%s' requires a value\n", who,
|
|
display);
|
|
} else if (match) {
|
|
fprintf(stderr, "xce_test: %s: flag '%s' does not take a value\n",
|
|
who, display);
|
|
} else {
|
|
const flagspec_t* suggestion = find_typo_suggestion(spec, n, bad_flag);
|
|
if (suggestion) {
|
|
char label[144];
|
|
format_flag_label(label, sizeof(label), suggestion);
|
|
fprintf(stderr,
|
|
"xce_test: %s: unrecognized flag '%s' (did you mean "
|
|
"'%s'?)\n",
|
|
who, display, label);
|
|
} else {
|
|
fprintf(stderr, "xce_test: %s: unrecognized flag '%s'\n", who,
|
|
display);
|
|
}
|
|
}
|
|
|
|
if (usage_text && *usage_text) {
|
|
size_t len = strlen(usage_text);
|
|
if (len > 0 && usage_text[len - 1] == '\n')
|
|
fprintf(stderr, "usage: %s", usage_text);
|
|
else
|
|
fprintf(stderr, "usage: %s\n", usage_text);
|
|
}
|
|
|
|
return -EINVAL;
|
|
}
|
|
|
|
char* flagspec_render_help(const flagspec_t* spec, size_t n)
|
|
{
|
|
if (!spec || n == 0)
|
|
return NULL;
|
|
|
|
char* buf = NULL;
|
|
size_t size = 0;
|
|
FILE* f = open_memstream(&buf, &size);
|
|
if (!f)
|
|
return NULL;
|
|
|
|
for (size_t i = 0; i < n; i++) {
|
|
const flagspec_t* fs = &spec[i];
|
|
|
|
if (fs->short_opt)
|
|
fprintf(f, " -%c, --%s", fs->short_opt, fs->long_opt);
|
|
else
|
|
fprintf(f, " --%s", fs->long_opt);
|
|
|
|
fprintf(f, " %s", fs->required ? "required" : "optional");
|
|
|
|
if (fs->default_desc && *fs->default_desc)
|
|
fprintf(f, " (default: %s)", fs->default_desc);
|
|
|
|
fprintf(f, "\n");
|
|
}
|
|
|
|
if (fclose(f) != 0) {
|
|
free(buf);
|
|
return NULL;
|
|
}
|
|
|
|
return buf;
|
|
}
|