import re
import sys
from pathlib import Path
SRC_API = Path(__file__).resolve().parent.parent / "src" / "api"
violations: list[str] = []
def is_response_file(p: Path) -> bool:
return p.name == "responses.rs" or p.parent.name == "responses"
def is_unit_test_file(p: Path) -> bool:
return p.name == "tests.rs" or p.parent.name == "tests"
def extract_tokio_test_bodies(path: Path):
lines = path.read_text().splitlines()
i = 0
while i < len(lines):
if lines[i].strip() == "#[tokio::test]":
fn_idx = i + 1
while fn_idx < len(lines) and "async fn" not in lines[fn_idx]:
fn_idx += 1
if fn_idx >= len(lines):
i += 1
continue
m = re.search(r"async\s+fn\s+(\w+)", lines[fn_idx])
fn_name = m.group(1) if m else "?"
body_lines: list[str] = []
depth = 0
started = False
for line in lines[fn_idx:]:
body_lines.append(line)
for ch in line:
if ch == "{":
depth += 1
started = True
elif ch == "}":
depth -= 1
if started and depth == 0:
break
yield fn_idx + 1, fn_name, "\n".join(body_lines)
i = fn_idx + len(body_lines)
else:
i += 1
def check_response_structs(path: Path) -> None:
lines = path.read_text().splitlines()
for i, line in enumerate(lines):
if not re.search(r"\bpub\s+struct\s+\w+", line):
continue
window = lines[max(0, i - 5) : i]
if not any("#[non_exhaustive]" in ln for ln in window):
violations.append(
f"[R1] {path}:{i + 1}: `pub struct` is missing `#[non_exhaustive]`"
)
if not any(
re.search(r"#\[derive\([^)]*Deserialize", ln) for ln in window
):
violations.append(
f"[R2] {path}:{i + 1}: `pub struct` is missing `Deserialize` in `#[derive(...)]`"
)
if not any(
'rename_all = "camelCase"' in ln or "serde(from" in ln
for ln in window
):
violations.append(
f'[R3] {path}:{i + 1}: `pub struct` is missing `#[serde(rename_all = "camelCase")]` or `#[serde(from = ...)]`'
)
def check_api_methods(path: Path) -> None:
lines = path.read_text().splitlines()
for i, line in enumerate(lines):
if not re.search(r"\bpub\s+async\s+fn\s+\w+", line):
continue
m = re.search(r"pub\s+async\s+fn\s+(\w+)", line)
fn_name = m.group(1) if m else "?"
doc_lines: list[str] = []
j = i - 1
while j >= 0 and lines[j].strip().startswith("///"):
doc_lines.append(lines[j])
j -= 1
if not doc_lines:
violations.append(
f"[A1] {path}:{i + 1}: `{fn_name}` has no doc comment"
)
continue
doc = "\n".join(doc_lines)
if "/api/v5/" not in doc:
violations.append(
f"[A1] {path}:{i + 1}: `{fn_name}` doc comment is missing the OKX endpoint path (`/api/v5/...`)"
)
if "# Errors" not in doc:
violations.append(
f"[A2] {path}:{i + 1}: `{fn_name}` doc comment is missing `# Errors`"
)
if "Authenticated" not in doc and "Public" not in doc:
violations.append(
f"[A3] {path}:{i + 1}: `{fn_name}` doc comment must state `Authenticated` or `Public`"
)
def check_unit_tests(path: Path) -> None:
for lineno, fn_name, body in extract_tokio_test_bodies(path):
if "MockTransport::new" not in body:
violations.append(
f"[T1] {path}:{lineno}: `{fn_name}` must use `MockTransport::new` (unit tests run offline)"
)
is_failure_test = ".unwrap_err()" in body
if not is_failure_test and "mock.captured()" not in body:
violations.append(
f"[T2] {path}:{lineno}: `{fn_name}` must call `mock.captured()` to assert the outgoing request"
)
if "mock.captured()" in body and "is_signed()" not in body:
violations.append(
f"[T3] {path}:{lineno}: `{fn_name}` calls `mock.captured()` but never checks `is_signed()`"
)
def main() -> int:
counts = {"responses": 0, "api.rs": 0, "tests": 0}
for path in sorted(SRC_API.rglob("*.rs")):
rel = path.relative_to(Path.cwd()) if path.is_relative_to(Path.cwd()) else path
if is_response_file(path):
print(f"[responses] {rel}")
check_response_structs(path)
counts["responses"] += 1
if path.name == "api.rs":
print(f"[api.rs ] {rel}")
check_api_methods(path)
counts["api.rs"] += 1
if is_unit_test_file(path):
print(f"[tests ] {rel}")
check_unit_tests(path)
counts["tests"] += 1
total = sum(counts.values())
summary = (
f"{counts['responses']} response file(s), "
f"{counts['api.rs']} api.rs file(s), "
f"{counts['tests']} test file(s) — {total} total"
)
print()
if violations:
print(f"Scanned {summary}\n")
print("CONTRIBUTING.md violations:\n")
for v in violations:
print(f" {v}")
print(f"\n{len(violations)} violation(s) — see CONTRIBUTING.md for the rules.")
return 1
print(f"Scanned {summary}")
print("All checks passed.")
return 0
if __name__ == "__main__":
sys.exit(main())