docs: refresh README against implementation and guard against drift

Bring README.md and RSYNC_COMPAT.md in line with the actual code/CLI and add
an automated guard so they cannot silently drift again.

Waves A-E:
- Correct stale compatibility claims: archive is `-rlptD` (owner/group are
  opt-in via identity flags, not implied), and symlinks, hard links, xattrs,
  ACLs and `--dirs` are implemented.
- Remove documented-but-nonexistent features: the six unread FASTSYNC_* env
  vars, and `--client-cn` (server-only) from the client table.
- Repair the corrupted "Implementation Details" section (broken list numbering
  and emphasis) and correct it against the source.
- Sync the client and server option tables with usage.c / server_cli.c, and
  document server-contacting `--dry-run` (protocol 2.21.0).
- Hygiene: `# FastSync` heading, real build commands, consistent binary names,
  runnable TLS examples, daemon module keys.

Also align the client `--help` / archive log wording and the RSYNC_COMPAT
archive rows with the opt-in ownership model, and add
tests/integration/test_readme_consistency.py (marked `ci`) asserting every
documented FASTSYNC_* var is read in src/ and every documented client/server
flag appears in the corresponding `--help`.
This commit is contained in:
2026-09-14 18:48:42 +02:00
parent df887c73b1
commit 81ad313ee5
6 changed files with 558 additions and 148 deletions
@@ -0,0 +1,262 @@
"""Guard the README against drifting from the real CLI.
This test parses README.md and checks it against the actual sources of truth
instead of against a hand-maintained copy:
* client ``--help`` output -> ``src/client/usage.c`` (``print_usage``)
* server ``--help`` output -> ``src/server/server.c`` (``print_server_usage``)
* ``FASTSYNC_*`` env vars -> ``getenv("...")`` call sites under ``src/``
It is deliberately offline and read-only: no server is started, no transfer is
performed. Each binary is invoked at most once per test session and the result
is cached.
"""
import functools
import os
import re
import subprocess
import sys
import pytest
sys.path.insert(0, os.path.dirname(__file__))
from common import BUILD_DIR, PROJECT_ROOT
pytestmark = pytest.mark.ci
README_PATH = os.path.join(PROJECT_ROOT, "README.md")
SRC_DIR = os.path.join(PROJECT_ROOT, "src")
# ---------------------------------------------------------------------------
# Markdown helpers
# ---------------------------------------------------------------------------
_HEADING_RE = re.compile(r"^(#+)\s+(.*?)\s*$")
_BACKTICK_RE = re.compile(r"`([^`]*)`")
# A documented option may carry an argument annotation that is not part of the
# option name itself: ``--out=FILE``, ``--exclude <pattern>``, ``--threads[=N]``,
# ``--copy-as=USER[:GROUP]``. Cut the name loose from the first such marker.
_OPTION_SUFFIX_RE = re.compile(r"[=\s<\[(].*$")
_OPTION_TOKEN_RE = re.compile(r"^--?[A-Za-z][A-Za-z0-9-]*$")
def _readme_lines():
with open(README_PATH, encoding="utf-8") as fh:
return fh.read().splitlines()
def _heading_level(line):
match = _HEADING_RE.match(line)
return len(match.group(1)) if match else 0
def _section(lines, heading):
"""Return ``(lineno, line)`` pairs under the first exact ``heading``.
The section runs until the next heading of the same or higher level, so a
``##`` section includes its ``###`` subsections. Line numbers are 1-based
to match what a reader sees in an editor.
"""
target_level = _heading_level(heading)
for index, line in enumerate(lines):
if line.strip() != heading:
continue
start = index + 1
for end in range(start, len(lines)):
level = _heading_level(lines[end])
if level and level <= target_level:
return [(n + 1, lines[n]) for n in range(start, end)]
return [(n + 1, lines[n]) for n in range(start, len(lines))]
raise AssertionError(
f"README heading not found (has the README been restructured?): {heading!r}"
)
def _first_column_spans(section_lines):
"""Backticked spans from the first column of every markdown table row."""
spans = []
for lineno, line in section_lines:
stripped = line.strip()
if not stripped.startswith("|"):
continue
cells = stripped.split("|")
if len(cells) < 2:
continue
first = cells[1]
if set(first.strip()) <= set("-: "):
continue # header separator row, e.g. |---|---|
for match in _BACKTICK_RE.finditer(first):
spans.append((match.group(1), lineno))
return spans
def _documented_option_tokens(section_lines):
"""``(token, lineno, raw_cell)`` for each CLI option in a section's tables."""
found = []
for raw, lineno in _first_column_spans(section_lines):
for piece in re.split(r"[,\s]+", raw):
name = _OPTION_SUFFIX_RE.sub("", piece).strip()
if _OPTION_TOKEN_RE.match(name):
found.append((name, lineno, raw))
return found
# ---------------------------------------------------------------------------
# Sources of truth
# ---------------------------------------------------------------------------
_GETENV_RE = re.compile(r'getenv\s*\(\s*"([^"]+)"\s*\)')
_FASTSYNC_ENV_RE = re.compile(r"FASTSYNC_[A-Z0-9_]+")
def _getenv_names():
"""Every string literal passed to ``getenv()`` anywhere under ``src/``."""
names = set()
for root, _dirs, files in os.walk(SRC_DIR):
for filename in files:
if not filename.endswith((".c", ".h")):
continue
path = os.path.join(root, filename)
with open(path, encoding="utf-8", errors="replace") as fh:
names.update(_GETENV_RE.findall(fh.read()))
return names
@functools.lru_cache(maxsize=None)
def _help_stdout(binary_name):
"""Cached ``<binary> --help`` stdout; skipped (not failed) if unbuilt."""
binary = os.path.join(BUILD_DIR, binary_name)
if not (os.path.isfile(binary) and os.access(binary, os.X_OK)):
pytest.skip(
f"{binary} is not built; run "
"`cmake -B build -S . && cmake --build build` first"
)
try:
result = subprocess.run(
[binary, "--help"], capture_output=True, text=True, timeout=30
)
except OSError as exc:
pytest.skip(f"could not execute {binary}: {exc}")
assert result.returncode == 0, (
f"{binary} --help exited {result.returncode}: "
f"{(result.stderr or result.stdout).strip()[:200]}"
)
return result.stdout
def _mentions_option(help_text, token):
"""True when ``token`` appears as a standalone option in ``help_text``.
A plain substring test would let a removed token hide behind a longer one
(``--del`` inside ``--delete``); requiring a non-word boundary on both sides
keeps every documented token individually accountable.
"""
return (
re.search(r"(?<![\w-])" + re.escape(token) + r"(?![\w-])", help_text)
is not None
)
# Options the help text intentionally expresses only as a family (for example
# the generic ``--no-OPTION`` entry) rather than by spelling every member out.
# Add an entry here only with a one-line justification; prefer fixing the
# extractor first. Currently empty: every option the README documents is
# printed verbatim by the matching ``--help`` (including ``--no-super`` and
# ``--no-detach``).
_FAMILY_FORM_ALLOWLIST = frozenset()
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_documented_env_vars_exist():
"""Every ``FASTSYNC_*`` in the README env table has a ``getenv()`` site.
Both directions are checked so the documented set and the source set stay
identical: a documented variable with no call site is a README defect, and a
new ``FASTSYNC_*`` call site without documentation is a README gap.
"""
documented = _documented_env_vars()
assert documented, "no FASTSYNC_* variables found in the README env table"
getenv_names = _getenv_names()
documented_names = {name for name, _lineno in documented}
missing_in_source = [
(name, lineno) for name, lineno in documented if name not in getenv_names
]
if missing_in_source:
details = "; ".join(
f"`{name}` (README.md line {lineno})"
for name, lineno in sorted(missing_in_source, key=lambda item: item[1])
)
pytest.fail(
"README documents environment variable(s) with no getenv() call "
f"site under src/: {details}"
)
fastsync_getenv = {name for name in getenv_names if name.startswith("FASTSYNC_")}
undocumented = sorted(fastsync_getenv - documented_names)
assert not undocumented, (
"src/ reads FASTSYNC_* environment variable(s) that the README does not "
f"document in '## Environment Variables': {', '.join(undocumented)}"
)
def test_documented_client_flags_exist_in_help():
"""Client options in README tables must appear in ``client --help``."""
_assert_documented_flags(
"client",
["### Client", "## Client Options", "## FastSync Extensions"],
)
def test_documented_server_flags_exist_in_help():
"""Server options in README tables must appear in ``server --help``."""
_assert_documented_flags("server", ["### Server", "## Server Options"])
# ---------------------------------------------------------------------------
# Implementation helpers for the tests above
# ---------------------------------------------------------------------------
def _documented_env_vars():
"""``(name, lineno)`` for each ``FASTSYNC_*`` token in the env table."""
lines = _readme_lines()
section = _section(lines, "## Environment Variables")
found = []
for raw, lineno in _first_column_spans(section):
for match in _FASTSYNC_ENV_RE.finditer(raw):
found.append((match.group(0), lineno))
return found
def _assert_documented_flags(binary_name, headings):
help_text = _help_stdout(binary_name)
readme_lines = _readme_lines()
failures = []
for heading in headings:
for token, lineno, raw in _documented_option_tokens(
_section(readme_lines, heading)
):
if token in _FAMILY_FORM_ALLOWLIST:
continue
if not _mentions_option(help_text, token):
failures.append((lineno, token, heading, raw))
if failures:
failures.sort()
shown = "\n".join(
f" {token} (README.md line {lineno}, section {heading!r}, "
f"table cell `{raw}`)"
for lineno, token, heading, raw in failures
)
pytest.fail(
f"README documents option(s) missing from `{binary_name} --help`:\n"
f"{shown}"
)
+1 -1
View File
@@ -188,7 +188,7 @@ static void test_cli_help() {
}
/* Test that the -a short spelling applies --archive's config bundle, matching
* rsync -rlptgoD semantics: links + metadata + devices + specials, and NOT
* rsync -rlptD semantics: links + metadata + devices + specials, and NOT
* compression/multithreading. (--archive itself is covered by
* test_parse_args_archive; this guards the short alias.) */
static void test_cli_archive_flags() {