Contributing
About 1040 wordsAbout 3 min
2026-07-31
The build is a handful of mise tasks, CI runs the same ones, and a green mise run check locally means a green pipeline.
The gate
mise run check # every gate CI runs: lint, tests, snapshots, crate boundaries
mise run test # just the tests
mise run run # the client, from source
mise tasks # everything availablecheck runs every gate CI runs: mise run lint (cargo fmt --all -- --check, cargo clippy --workspace --all-targets -- -D warnings, yamllint and actionlint), cargo test --workspace, the pending-snapshot check, ./scripts/check-crate-boundaries.sh, and the release signing-identity check. All of them have to pass before anything merges.
The last one is worth explaining. It greps cargo tree and fails if tgt-core has picked up ratatui or crossterm, or if tgt-ui has picked up tdlib-rs. Those bans are what keep the domain testable without a terminal and the renderer testable without a network, and a transitive dependency can break them by accident, so they're checked rather than trusted.
Toolchain comes from mise: Rust 1.99.0 and cargo-insta 1.49.0, pinned exactly like every other tool in .mise.toml. rust-toolchain.toml pins the compiler independently so plain cargo picks the right one outside a mise shell. Editing .mise.toml needs a mise trust before the tasks run again.
Your build sends no crash reports
The Sentry DSN is read with option_env!("TGT_SENTRY_DSN") at compile time, and only the release workflow sets it. So anything you build (cargo build, mise run install, a fork's CI) never calls sentry::init at all: no panic hook, no uploader, no network attempt. Forks don't report into this project's Sentry, which is the point, and the first-run consent screen says so instead of offering to enable something the binary can't do.
The catch is that it applies to maintainers too. If you daily-drive a binary you built yourself and want its crashes to reach the project, export the DSN before building:
TGT_SENTRY_DSN="https://…@….ingest.de.sentry.io/…" mise run installLeave it unset and the inert path is what you're testing, which is worth doing deliberately once in a while since it's what nearly every user of a source build gets.
Snapshots
Rendering is pinned by insta snapshots in three places: crates/ui/src/render/snapshots/, crates/ui/src/view/snapshots/, and crates/ui/tests/snapshots/ for full-frame regressions.
mise run snapshots # fail on any pending snapshot
cargo insta test -p tgt-ui --check
cargo insta acceptRead the diff before accepting. These snapshots are the only thing keeping the visual design from drifting, and accepting one without looking is how a layout regression ships.
Testing without an account
Full-app integration tests in crates/app/tests/ drive the real runtime loop against FakeTd, which replays recorded TDLib sessions from JSONL fixtures. No network, no account, no credentials.
Each test binary carries an #[ignore]d regenerate_fixtures test that rewrites its .jsonl from a Rust script:
cargo test -p tgt-app --test <name> regenerate_fixtures -- --ignoredEffects dispatch through tokio::spawn, so assert on state transitions or on what FakeTd received, not on state read immediately after a step.
Documents are the contract
Three engineering documents live in the repo and are deliberately not part of this website. They're written for people changing the code, they're long, and two of them outrank the code when the two disagree.
| Document | What it is |
|---|---|
docs/architecture.md | The inter-module contract: every shared type, handler signature, module responsibility and dependency pin, plus the amendments discovered during implementation. Renaming or reshaping a shared type means editing this document first, then the code. |
docs/design-language.md | The visual rules: chrome, hierarchy, message rendering, attachments, selection, inline images, themes. "Separate regions with space and contrast, not with lines" is the founding rule, and the line budget for the main view is exactly two rules. |
docs/plan.md | The completed build plan. Useful as an index of which task built what, since commit messages reference task numbers. |
docs/superpowers/specs/ | The product spec. Behaviour decisions there are settled. |
.claude/CLAUDE.md is a condensed orientation covering the same ground, and it's useful whether or not you're an agent. Its "Gotchas" section is the fastest way to avoid re-discovering things that already bit someone.
Gotchas in one place
The short version of that section, because these are the ones that cost time:
- Telegram entity offsets are UTF-16 code units. Conversion to byte offsets happens in exactly one module, tested against a 14-row table. Never slice message text by entity offsets anywhere else.
- Chat order comes from TDLib and is never computed locally. See why.
- An empty
getChatHistoryis not end-of-history. See how that's handled. - Layout cache keys are
(message_id, width, theme_generation, spoilers_revealed). Anything that changes without one of those (reactions, receipts, download progress) must render outside the cached block. MessageCapsdon't arrive onmessage. They come fromGetMessageProperties, fetched when a message is selected.- Nothing writes to stdout or stderr while the TUI is active. The panic hook restores the terminal before printing.
- Dependency pins are exact (
=) and live only in the threeCargo.tomlfiles, several with non-obvious feature choices explained in comments.
Commits and releases
Commit and PR titles follow Conventional Commits. release-please turns them into version bumps, changelog entries and GitHub releases. While the project is pre-1.0, a breaking change bumps the minor version and everything else bumps the patch. A bot validates PR titles, so a wrong one gets caught before merge rather than becoming a wrong release.
This website
The site lives under docs/, built with VuePress and the Plume theme, using bun.
cd docs
bun install
bun run dev # local preview with hot reload
bun run build # what CI buildsThe engineering documents above sit in the same directory and are excluded from the built site via pagePatterns in docs/.vuepress/config.ts. If you add a new one, exclude it there and in .markdownlint-cli2.yaml too.
Markdown is linted with markdownlint-cli2 over docs/**/*.md, the same glob the workflow uses.
