rust/ is owned by the generator/runtime lane and is self-contained: make -C rust check
runs every gate the crate has. Everything a language needs outside its own directory —
root Makefile targets, parity readers, rosters, CI, docs — is enumerated here for the
integration owner to land in one commit, exactly as SPEC §21 and rustB-contract.md §2
list them. Nothing in this file has been applied to the shared files from the rust/ lane.
Add a rs-* block modelled on the Swift block, delegating to rust/Makefile:
#------------------------------------------------------------------------------
# Rust SDK targets
#------------------------------------------------------------------------------
.PHONY: rs-build rs-test rs-lint rs-doc rs-deny rs-generate-services rs-check-drift rs-publish-check rs-check rs-clean
rs-build:
@echo "==> Building Rust SDK..."
$(MAKE) -C rust build
rs-test:
@echo "==> Testing Rust SDK..."
$(MAKE) -C rust test test-features
rs-lint:
$(MAKE) -C rust lint
rs-doc:
$(MAKE) -C rust doc
rs-deny:
$(MAKE) -C rust deny
# Regenerate rust/basecamp-sdk/src/generated from openapi.json + behavior-model.json
rs-generate-services:
@echo "==> Generating Rust SDK..."
$(MAKE) -C rust generate
# Regenerate-and-diff freshness gate (the Swift model; see scripts/check-rust-service-drift.sh)
rs-check-drift:
@./scripts/check-rust-service-drift.sh
rs-publish-check:
$(MAKE) -C rust publish-check
rs-check: rs-lint rs-test rs-doc rs-deny rs-check-drift rs-publish-check
rs-clean:
$(MAKE) -C rust cleangenerate:— add@$(MAKE) rs-generate-servicesbefore thesync-api-versionlines (the generatedrust/basecamp-sdk/src/generated/mod.rscarriesAPI_VERSION, whichsync-api-version.shalso rewrites; see §5).check-targets— addrs-check-drift rs-check.clean:— addrs-clean.help:— add thers-*lines.conformance-rust/conformance-runner-tests-rustare W3's (conformance/runner/rust).
The Swift model — regenerate into a temp dir and diff -rq, never in-place:
#!/bin/bash
# check-rust-service-drift.sh
#
# Verifies that the committed generated Rust artifacts are current by
# regenerating the whole generated/ tree into a temp directory and diffing.
# Generation needs only the Rust toolchain and rustfmt. It is non-mutating:
# the committed tree is never touched.
#
# Exit codes:
# 0 = No drift detected
# 1 = Drift detected
# 2 = cargo not available
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
if ! command -v cargo >/dev/null 2>&1; then
echo "ERROR: cargo is required for check-rust-service-drift.sh but was not found" >&2
exit 2
fi
GENERATED_DIR="$ROOT_DIR/rust/basecamp-sdk/src/generated"
TMP_OUT="$(mktemp -d "${TMPDIR:-/tmp}/rust-service-drift.XXXXXX")"
trap 'rm -rf "$TMP_OUT"' EXIT
echo "==> Regenerating Rust SDK into a temp directory..."
(cd "$ROOT_DIR/rust" && cargo run -q -p basecamp-sdk-generator -- --root "$ROOT_DIR" --output "$TMP_OUT") > /dev/null
echo "==> Diffing against committed rust/basecamp-sdk/src/generated/ ..."
if ! diff -rq "$GENERATED_DIR" "$TMP_OUT" > /dev/null; then
echo "ERROR: Generated Rust is out of date. Run 'make rs-generate-services'"
diff -rq "$GENERATED_DIR" "$TMP_OUT" || true
exit 1
fi
echo "No drift detected."
exit 0(cargo run -p basecamp-sdk-generator -- --check does the same in-process and is what
make -C rust generate-check runs; the script exists so the root gate has the same shape
as the other five.)
All five are exact-source readers and fail on a partial roster, which is why none of them
was touched from the rust/ lane. The shapes they read are stable now that all 265
operations are emitted.
Reader (labelled, one operation per line — metadata.rs is #[rustfmt::skip]):
def from_rust() -> dict[str, tuple]:
text = (ROOT / "rust/basecamp-sdk/src/generated/metadata.rs").read_text()
# pub static GET_PROJECT: OperationMetadata = OperationMetadata { operation: "GetProject", idempotent: false, readonly: true, retry: RetryConfig { max_attempts: 3, base_delay_ms: 1000, backoff: Backoff::Exponential, retry_on: &[429, 503] } };
pat = re.compile(
r'operation:\s*"(?P<op>\w+)",\s*idempotent:\s*(?:true|false),\s*readonly:\s*(?:true|false),\s*'
r"retry:\s*RetryConfig\s*\{\s*max_attempts:\s*(?P<max>\d+),\s*base_delay_ms:\s*(?P<delay>\d+),\s*"
r"backoff:\s*Backoff::(?P<backoff>\w+),\s*retry_on:\s*&\[(?P<ro>[^\]]*)\]"
)
out: dict[str, tuple] = {}
for m in pat.finditer(text):
ro = tuple(int(x) for x in re.findall(r"\d+", m.group("ro")))
out[m.group("op")] = (int(m.group("max")), int(m.group("delay")), m.group("backoff").lower(), ro)
return outmain(): errors += check_full_tuple("Rust metadata.rs", from_rust(), model).
Consumer row (Rust consumes the full tuple; the loop lives in client.rs, the curve in
retry.rs):
("Rust", "full tuple", "rust/basecamp-sdk/src/client.rs",
["retry.retry_on.contains", "effective_attempts(self.shared.config.max_retries, retry.max_attempts)", "backoff_with_jitter(retry, retry_index"], [],
"client.rs: retry_on.contains(status), min(cap, max_attempts) via effective_attempts, backoff_with_jitter over base_delay_ms/backoff"),
("Rust", "full tuple", "rust/basecamp-sdk/src/retry.rs",
["config.base_delay_ms", "config.backoff", "Backoff::Exponential", "MAX_BACKOFF_DELAY_MS"], [],
"retry.rs backoff_ms: base_delay_ms × curve, saturating at MAX_BACKOFF_DELAY_MS"),Docstring: add rust/basecamp-sdk/src/generated/metadata.rs (labelled) to the full-tuple list.
# ---- Rust (metadata.rs, labelled `operation: "Op", idempotent: true`) --------
grep -oE 'operation: "[[:alnum:]]+", idempotent: true' \
rust/basecamp-sdk/src/generated/metadata.rs \
| sed -E 's/operation: "([[:alnum:]]+)".*/\1/' | sort > "$WORK/rust"
compare "Rust idempotent set == $expected_idempotent" "$WORK/idempotentSet" "$WORK/rust"Header comment: "six SDKs" → seven.
A SOURCES entry. The Rust generator does not put the literal path in the call (it calls
self.client.operation(&routes::CONST, …)), so each generated method carries a rustdoc
line /// \GET /projects/{projectId}` — …emitted from the sameOperation` record the
route constant is emitted from; that line is the extraction point.
{
id: "rust",
path: "rust/basecamp-sdk/src/generated/services",
ext: ".rs",
drop: ["mod"],
class_pattern: /^pub struct (\w+)<'a> \{/,
calls: [
# The generator writes the wire line into every method's rustdoc from the same
# record the route constant is emitted from (rust/generator/src/emit/services.rs).
{ pattern: %r{^ /// `(?<method>GET|POST|PUT|DELETE|PATCH) (?<path>/[^`]*)` — } },
],
},canonical_service already strips a trailing service (ProjectsService → projects).
The XParams structs in the same files are pub struct ListTodosParams { — no <'a> — so
the class pattern does not match them. Success line: "all 6 generated SDKs".
Two entries: the services directory and the accessor file.
{
id: "rust",
path: "rust/basecamp-sdk/src/generated/services",
kind: :directory,
ext: ".rs",
drop: ["mod"],
spelling: :snake,
},
{
id: "rust-accessors",
path: "rust/basecamp-sdk/src/generated/accessors.rs",
kind: :file,
pattern: /^ pub fn ([a-z][a-z0-9_]*)\(&self\) -> services::[a-z0-9_]+::[A-Za-z0-9]+Service<'_> \{/,
spelling: :snake,
},Bump the prose ("six SDKs", "eight renderings" → ten).
Rust is in the compiler-warning class (#[deprecated(note = "…")]), like Kotlin:
# ---- Rust (compiler warning: #[deprecated]) ---------------------------------
RS_SVC=rust/basecamp-sdk/src/generated/services/search.rs
assert_contains "$RS_SVC" '#[deprecated(note = "prefer type_names[].")]' "Rust: search 'type' param marker"
assert_contains "$RS_SVC" '#[deprecated(note = "prefer bucket_ids[].")]' "Rust: search 'bucket_id' param marker"
assert_contains "$RS_SVC" '#[deprecated(note = "prefer creator_ids[].")]' "Rust: search 'creator_id' param marker"
assert_count "$RS_SVC" '#\[deprecated\(' 3 "Rust: exactly three param markers (controls clean)"
RS_TYPES=rust/basecamp-sdk/src/generated/types.rs
# rustfmt wraps the long note onto its own line, so the attribute and its note are two lines.
assert_count "$RS_TYPES" '^#\[deprecated\($' 1 "Rust: the ClientSide struct is marked"
assert_count "$RS_TYPES" '^ #\[deprecated\($' 1 "Rust: the Project.clientside field is marked"
assert_count "$RS_TYPES" 'note = "This shape is deprecated since 2024-01: Use Client Visibility feature instead"' 2 "Rust: both markers carry the reason"
assert_contains "$RS_SVC" '#[allow(deprecated)]' "Rust: search method reads its deprecated params under an allow"
assert_field_undeprecated "$RS_SVC" "pub type_names: Option<Vec<String>>," "Rust control: type_names replacement is present and unmarked"Add rust/basecamp-sdk/src/generated to the doubled-marker grep -rIn list.
SDKS["Rust"]: readme rust/basecamp-sdk/README.md, source rust/basecamp-sdk/src,
suffixes .rs, comment style // and /* */ (no nesting), string quotes " only
(plus raw r"…"/r#"…"#), env read pattern env::var\(\s*"(?P<name>[A-Z_][A-Z0-9_]*)".
The only reads are in config.rs::from_env (BASECAMP_BASE_URL, BASECAMP_TIMEOUT,
BASECAMP_MAX_RETRIES), all named in the README's "Environment variables" section. The root
README sentence about which SDKs read no env vars is unaffected (Rust reads three).
sync-api-version.sh:sedi "s/^pub const API_VERSION: &str = \".*\";/pub const API_VERSION: \&str = \"$API_VERSION\";/" rust/basecamp-sdk/src/generated/mod.rs(the generator writes the same value, somake generate's secondsync-api-versionis a no-op for Rust).sync-api-version-check: grep the same line.bump-version.sh: version filerust/basecamp-sdk/Cargo.toml(^version = "…"inside[package], first match only — the workspace manifest carries no version); then(cd rust && cargo update -p basecamp-sdk --offline)to restamprust/Cargo.lock, and the same inconformance/runner/rustonce W3 lands. Bump the hardcoded counts in its final echo (+1 version file, +2 lockfiles).release:guard:cargo metadata --no-deps --format-version 1 --manifest-path rust/Cargo.toml | jq -r '.packages[] | select(.name=="basecamp-sdk") | .version'.scripts/assert-lockfiles-unchanged: addCargo.lockto thefindnames and the pathspec.
test.ymljobtest-rust: matrix{ toolchain: [1.88, stable] };dtolnay/rust-toolchainat the matrix value (stable=rust/rust-toolchain.toml, i.e. 1.98.1);Swatinem/rust-cachewithworkspaces: rust; steps in order:cargo fmt --all --check(stable),cargo clippy --workspace --all-targets --all-features --locked -- -D warnings(stable),cargo test --workspace --all-features --locked(both),cargo test -p basecamp-sdk --no-default-features --locked(both),RUSTDOCFLAGS="-D warnings" cargo doc -p basecamp-sdk --no-deps --all-features(stable),EmbarkStudios/cargo-deny-actionwithmanifest-path: rust/Cargo.toml(stable),scripts/check-rust-service-drift.sh,cargo publish -p basecamp-sdk --dry-run --locked(stable),obi1kenobi/cargo-semver-checks-action(manifest-path: rust/basecamp-sdk/Cargo.toml,continue-on-error: trueuntil a crates.io baseline exists), then W3's runner andscripts/assert-lockfiles-unchanged --verify-clean.security.yml:cargo-denyjob overrust/Cargo.toml(advisories + licenses;deny.tomlis checked in).dependabot.yml:cargoat/rust(groupstokio-ecosystem,serde,cargo-dependencies; prefixdeps(rust):; labelsdependencies, rust) and/conformance/runner/rust.labeler.yml:rust: - changed-files: - any-glob-to-any-file: "rust/**".codeql.yml: no Rust analyzer in the matrix builder — record the gap in SECURITY.md; clippy + cargo-deny cover it.
- Crate README:
rust/basecamp-sdk/README.md(crates.io landing page;#![doc = include_str!]inlib.rs, so every fence is a doctest — keeprust,no_runon network snippets). - Root README rows: language
Rust→rust/, packagebasecamp-sdk(crates.io; publish is gated on the Trusted Publishing bootstrap), docshttps://docs.rs/basecamp-sdk; install linecargo add basecamp-sdk; MSRV 1.88; feature matrix column: retries ✓, pagination ✓, ETag cache ✗ (follow-up card), hooks ✓, OAuth ✓ (device flow withlogin_hint), webhooks ✓ (signature verify), download ✓, event feed ✗ (§23 deferred). - SPEC.md rows: §7 caps — client cap
max_retries(total attempts,0legal), per-op ceiling honoured, backoff overflow: log-domain saturating (retry.rs); §8 page param —XParams.page, pinned page never followed (following is explicit vianext_page/collect_all); §9 header table —basecamp-sdk-rust/{VERSION} (api:{API_VERSION}); §10 integer width —i64ids,Person.idviaflexible_i64,FlexIntdimensions viaflex_int; §9 truncation unit — bytes, cut back to a char boundary; §16 OAuth applicability — device flow, PKCE, exchange, refresh, discovery (see the oauth module report); §21 gate table —rs-check, runnerconformance/runner/rust; Appendix F — §17 ETag cache not shipped, §23 event feed not shipped (featureevent-feedreserved),on_paginatehook omitted (allowed). - SECURITY.md: "all seven implementations";
### Rust— rustls by default (native-tlsadditive opt-in), PKCE S256, HTTPS enforced with the localhost carve-out, sensitive headers redacted, signed download URLs never rendered (origin only), 401 refresh row: "at most once per request, budget-gated beforerefresh(), concurrent 401s coalesce into one refresh". - AGENTS.md: architecture row
Rust | reqwest via HttpClient trait | rust/basecamp-sdk/src/generated/services/*.rs; infra rows (client.rs/retry.rs/pagination.rs/hooks.rs; OAuthrust/basecamp-sdk/src/oauth/; compositesrust/basecamp-sdk/src/services/{todos,todolists,documents,schedules,cards,uploads}.rs); Hard Rule 2's TAG_TO_SERVICE list gainsrust/generator/names.toml; "all 7 workflows" → 8. - CONTRIBUTING.md: prerequisites row
Rust | 1.88+ (MSRV), rust-toolchain.toml pins the dev toolchain; cargo-deny; build blockcd rust && make check; generator linerust/generator(make rs-generate-services). - MIGRATING.md
# Unreleased:### Rust: new SDK.
rust/is a two-member workspace (basecamp-sdk+generator), not a single crate: the generator is a Rust binary and must live somewhere Cargo can build it.- MSRV is fixed at
1.88(the plan's decision), not the standard's1.85;clippy.tomlmsrvmatches. native-tlsis an additive feature besiderustls-tls(Codex #12 in the plan);deny.tomltherefore does not banopenssl-sys.[workspace.lints]carries the standard's tables plus two pedantic allows with reasons (struct_excessive_bools,return_self_not_must_use).