Crate Boundaries: The Full Specification

For each crate: responsibility, what it must not know about, public API, internal state, dependencies, test strategy, platform independence — and, added here because it is the part that makes a boundary real, the concrete capability lost if the boundary breaks.

The workspace chapter gave you these as a plan. This chapter is the audit: check your actual code against each row.


1. terminal-protocol

FieldValue
ResponsibilityBytes → parsed actions. The VT state machine and the UTF-8 decoder.
Must not know aboutScreens, grids, cursors, colors, PTYs, files, threads, rendering, terminals. It does not know that CSI 2 J erases anything.
Public APIParser::new(), Parser::advance(&mut self, &[u8], &mut impl Perform), the Perform trait, Params.
Internal stateParser state, parameter accumulator, intermediates, UTF-8 partial buffer. All bounded and Copy-sized.
DependenciesNone. std only (and core+alloc under no_std).
Test strategyUnit tests over a recording Perform. The split-input property at chunk sizes 1, 2, 3, 7, 13, ∞. Fuzzing for no-panic/no-hang/bounded-memory.
Platform independentYes, absolutely. Compiles for wasm32, no_std, and any target.
If the boundary breaksYou lose: independent fuzzing (the state space explodes if a screen is attached), reuse outside terminals (log colorizers, asciinema renderers), and the ability to test parsing without constructing a screen.

Audit command:

cargo tree -p terminal-protocol --depth 1     # must show ONLY the crate itself
cargo build -p terminal-protocol --target wasm32-unknown-unknown
grep -rn "cfg(unix)\|cfg(windows)\|cfg(target_os" crates/terminal-protocol/src/   # empty

2. terminal-core

FieldValue
ResponsibilityActions → screen state. Grid, cursor, modes, scroll region, scrollback, alternate screen, tab stops, title, hyperlink table, damage. Implements each sequence's meaning.
Must not know aboutFonts, pixels, GPUs, windows, PTYs, processes, sockets, threads, file descriptors, or how bytes arrived.
Public APITerminal::new(rows, cols, config), advance(&[u8]), resize(rows, cols), screen(), cursor(), modes(), damage(), clear_damage(), take_replies(), take_clipboard_write(), take_title_changed(), snapshot().
Internal stateScreen { primary, alternate, active, scrollback }, Cursor, TerminalModes, (scroll_top, scroll_bottom), tab_stops, title, replies, damage, hyperlinks.
Dependenciesterminal-protocol, unicode-width, bitflags. Nothing else, ever.
Test strategyPer-sequence unit tests (screen before → bytes → screen after). Golden tests over recorded streams. Property tests for structural invariants (cursor in bounds, every row exactly cols, no orphan spacers).
Platform independentYes. This is the crate that makes a headless simulator, a WASM demo, a mux server, and a test harness possible.
If the boundary breaksYou lose: the multiplexer (a core that needs a display cannot run headless), WASM, fast tests, and the four-consumer property that is Milestone 13's whole point.

The take_* pattern deserves a note. terminal-core does no I/O, but several sequences require an effect outside the screen: CSI 6n needs a reply written to the PTY; OSC 52 needs the clipboard set; OSC 0 needs the window title changed. Rather than taking callbacks (which would put the caller's types into the core's signatures) or doing I/O (which would destroy portability), the core queues requests the caller drains. That inversion is the single most important API decision in the workspace.

cargo tree -p terminal-core | grep -E 'nix|rustix|libc|mio|tokio|winit|wgpu'   # must be empty
cargo build -p terminal-core --target wasm32-unknown-unknown

3. terminal-input

FieldValue
ResponsibilityKey and mouse events → bytes, honoring terminal modes.
Must not know aboutwinit, any windowing library's types, the screen contents, or pixels. It defines its own Key/Modifiers/MouseEvent.
Public APIencode_key(Key, Modifiers, &TerminalModes) -> Option<Vec<u8>>, encode_mouse, encode_paste.
Internal stateNone. Pure functions of (event, modes). Any state (e.g. a kitty flag stack) belongs in the modes, in terminal-core.
Dependenciesterminal-core — and only for the mode flags type.
Test strategyTable-driven: one row per entry in the key encoding table. Cross-terminal differential comparison against xterm's actual output.
Platform independentYes. The encoding is a wire protocol, not an OS feature.
If the boundary breaksYou lose: unit-testing input without a window (which is most of the test suite), reuse by the mux client, and the ability to swap windowing libraries.

Note: The dependency on terminal-core is the weakest link in the graph, and it exists only for TerminalModes. Moving the mode flags into terminal-protocol (where they arguably belong — they are protocol state, not screen state) would make terminal-input depend on nothing but the protocol. That is a real refactor worth considering, and noticing it is exactly the kind of thing this audit is for.


4. terminal-pty

FieldValue
ResponsibilityAllocate a PTY pair, spawn a child correctly, own the master, get/set the size, reap the child, expose I/O and an exit notification.
Must not know aboutEscape sequences, screens, keys, rendering. If this crate contains the byte 0x1b, something is wrong.
Public APIPty::spawn(&PtyConfig), master()/AsFd, read/write, resize(PtySize), try_wait(), child_pid(), kill().
Internal stateThe master OwnedFd, the child Pid, the last PtySize, an exit status.
Dependenciesrustix or nix (or raw libc). Optionally portable-pty behind a feature.
Test strategyIntegration tests spawning real processes: echo produces CRLF, stty size reflects the set size, exit status observed, resize produces SIGWINCH.
Platform independentNo — and it is the only crate allowed not to be.
If the boundary breaksYou lose: Windows support (ConPTY behind the same API), and the ability to test everything else without spawning processes.

This crate's non-portability is a feature. It is the designated place for cfg(unix) and cfg(windows), and its existence is what keeps every other crate clean. Adding a ConPTY backend should require changing exactly one crate.

# Everything EXCEPT terminal-pty and terminal-gui must be cfg-free:
grep -rn "cfg(unix)\|cfg(windows)\|cfg(target_os" crates/ \
  | grep -v terminal-pty | grep -v terminal-gui        # must be empty

5. terminal-render-model

FieldValue
ResponsibilityTerminal state → a flat, renderer-agnostic description: styled runs, resolved colors, cursor, selection, damage.
Must not know aboutFonts, glyph atlases, GPUs, pixel coordinates. It speaks in cells, never in pixels.
Public APIRenderSnapshot::from_terminal(&Terminal, &Theme, Option<Selection>), RenderSnapshot, RenderLine, StyledRun, RenderCursor.
Internal stateNone; a pure transformation, so a snapshot can be produced on any thread and sent to a renderer.
Dependenciesterminal-core.
Test strategySnapshot tests (known screen → known run list). Damage tests. Color-resolution tests including inverse-plus-selection composition.
Platform independentYes.
If the boundary breaksYou lose: swapping CPU for GPU without touching the core, rendering off-thread, testing rendering logic, and a second frontend.

This is the crate people skip, and skipping it is why terminal codebases end up with renderers reaching into Grid's private fields.


6. terminal-gui

FieldValue
ResponsibilityWindow, event loop, translate windowing events into terminal-input types, drive the PTY, feed the core, rasterize, blit, handle resize and clipboard.
Must not know aboutHow escape sequences work. It should be readable by someone who does not know what CSI means.
Public APIA binary. If a library surface exists, App::run(config).
Internal stateWindow, surface, fonts, atlas, cell metrics, Terminal, Pty, selection, scroll offset.
Dependencieswinit, softbuffer/wgpu, fontdue/swash, plus the internal crates.
Test strategyKeep it thin. Extract everything testable (pixel↔cell math, cell metrics, selection ranges) into pure functions. Prefer testing RenderSnapshot over golden images.
Platform independentMostly, via winit. Font discovery and clipboard are the platform-specific parts.
If the boundary breaksIf the core starts depending on this, you lose everything — headless operation, the mux, tests, WASM. This is the arrow that must never exist.

7. terminal-mux

FieldValue
ResponsibilitySessions → windows → panes, each pane a Pty + a headless Terminal. The server event loop, the socket protocol, input routing, compositing, attach/detach.
Must not know aboutWindows, fonts, pixels, GPUs. Never depends on terminal-gui.
Public APITwo binaries plus a library: SessionManager, Session, Window, Pane, Layout, Request, Event.
Internal stateThe session tree, the listener, per-client state, the poll registry.
Dependenciesterminal-core, terminal-pty, terminal-protocol, serde, mio/libc.
Test strategyProtocol round-trip. Server tests over a socketpair with no terminal. The kill -9 persistence test. Layout property tests.
Platform independentUnix-oriented (Unix sockets). Session and layout logic is portable; keep the transport behind a trait.
If the boundary breaksIf it depends on the GUI, you lose detach/attach entirely — the whole point.
cargo tree -p terminal-mux | grep -E 'winit|wgpu|softbuffer|fontdue|cosmic-text'   # must be empty

8. terminal-debugger

FieldValue
ResponsibilityHex dumps with decoded escapes, UTF-8 decode logging, parser state tracing, action printing, screen snapshots, snapshot diffing, session record/replay.
Must not know aboutRendering. It is a text tool.
Public APIHexDump, SequenceDecoder, TracingPerform<P>, Recorder, Recording, snapshot_diff.
Internal stateBuffers and a writer.
Dependenciesterminal-protocol, terminal-core.
Test strategyRound-trip tests on the record format; decoder tests on known sequences.
Platform independentYes for replay; recording needs a PTY.
If the boundary breaksYou lose the ability to debug the core without a terminal — which is when you most need it.

9. terminal-cli

FieldValue
ResponsibilityRun a command through a PTY at a fixed size and print a deterministic snapshot; replay recordings; diff snapshots.
Must not know aboutRendering, windowing.
Public APIA binary: run, replay, feed, diff.
Internal stateTrivial.
Dependenciesterminal-core, terminal-pty, terminal-debugger, clap.
Test strategyIt is one.
Platform independentreplay yes; run needs a PTY.
If the boundary breaksYou lose golden tests, which is most of your regression safety.

Configuration: The Concern Everyone Gets Wrong

Concern 8 from the section index. Configuration is where clean architectures go to die, because it is tempting to make it global.

#![allow(unused)]
fn main() {
// ✗ WRONG. Now terminal-core depends on a file format, a filesystem, a global,
// and initialization order. It no longer compiles for WASM. It cannot be
// instantiated twice with different settings — which the MUX needs.
static CONFIG: OnceLock<Config> = OnceLock::new();
impl Terminal {
    fn new(rows: usize, cols: usize) -> Self {
        let scrollback = CONFIG.get().unwrap().scrollback_limit;   // NO
        // ...
    }
}

// ✓ RIGHT. Configuration is DATA, passed down. The core does not know what TOML
// is, cannot read a file, and can be instantiated a hundred times with a
// hundred different configurations — which is exactly what the mux does.
#[derive(Clone, Debug)]
pub struct TerminalConfig {
    pub scrollback_limit: usize,
    pub tab_width: usize,
    pub ambiguous_width_is_wide: bool,
    pub clipboard_policy: ClipboardPolicy,
    pub max_osc_len: usize,
}
impl Default for TerminalConfig { /* documented, sensible defaults */ }

impl Terminal {
    pub fn new(rows: usize, cols: usize, config: TerminalConfig) -> Self { /* ... */ }
}
}

The rule: each crate defines the config struct it needs, with Default. The application parses the file, builds each struct, and passes them down. Only terminal-gui and the mux binaries know that TOML exists.

Live reload then falls out for free: the app re-parses and calls terminal.set_config(new). No global to invalidate, no ordering problem.


The Audit: Run This Against Your Code

#!/usr/bin/env bash
set -e
echo "=== 1. terminal-protocol has no dependencies ==="
test "$(cargo tree -p terminal-protocol --depth 1 | wc -l)" -le 2

echo "=== 2. terminal-core has no OS dependencies ==="
! cargo tree -p terminal-core | grep -E 'nix|rustix|libc|mio|tokio|winit|wgpu'

echo "=== 3. terminal-mux has no GUI dependencies ==="
! cargo tree -p terminal-mux | grep -E 'winit|wgpu|softbuffer|fontdue|cosmic-text'

echo "=== 4. terminal-input has no windowing dependencies ==="
! cargo tree -p terminal-input | grep -E 'winit|wgpu|softbuffer'

echo "=== 5. The portable crates build for wasm32 ==="
cargo build --target wasm32-unknown-unknown \
  -p terminal-protocol -p terminal-core -p terminal-render-model -p terminal-input

echo "=== 6. Platform cfgs are confined to pty and gui ==="
! grep -rn 'cfg(unix)\|cfg(windows)\|cfg(target_os' crates/ \
    --include=*.rs | grep -v terminal-pty | grep -v terminal-gui

echo "=== 7. Public APIs are documented ==="
cargo doc --no-deps --workspace 2>&1 | grep -q 'missing documentation' && exit 1 || true

echo "=== 8. No escape bytes in terminal-pty ==="
! grep -rn '0x1b\|\\x1b\|\\033' crates/terminal-pty/src/

echo "ALL BOUNDARY CHECKS PASSED"

Put this in CI. It is eight commands, it runs in seconds, and it is the difference between a designed architecture and a described one.


Reading Other Projects Against This Map

The best way to confirm the pattern is general is to find it elsewhere:

ProjectWhere the boundary is
Alacrittyvte = your terminal-protocol; alacritty_terminal = your terminal-core; alacritty = your terminal-gui. Published separately, used by others.
WezTermtermwiz = protocol + core + input; portable-pty = your terminal-pty, with a Windows backend.
Ghosttylibghostty = protocol + core + input + pty, behind a C ABI; the Swift/GTK apps = your terminal-gui.
kittyLess separated — the core is in C inside the application, with Python for the UI layer. A different trade-off, deliberately.
ZellijA Rust multiplexer using vte for parsing. Maps onto your terminal-mux + terminal-protocol.

Exercise: clone one of these, run cargo tree or read its module layout, and produce the mapping table onto your nine crates. Where it differs, ask what it bought them. That exercise is worth more than another chapter of prose.


Validation / Self-check

  1. For each of the nine crates: responsibility, one thing it must not know, and the capability lost if that boundary breaks.
  2. Which crate is allowed to be platform-specific, and why is it a virtue that it is the only one?
  3. Explain the take_* pattern. Why not callbacks? Why not I/O?
  4. Why does terminal-input depend on terminal-core, and what refactor would remove it?
  5. Why must configuration be passed down rather than read from a global? Name three things a global costs you.
  6. Write the eight audit commands from memory.
  7. Which arrow in the dependency graph must never exist, and what does it cost?
  8. Map alacritty_terminal, vte, termwiz, and libghostty onto your crates.
  9. Your renderer needs a cell's hyperlink URI. Which crate exposes it, and how, without putting a String in Cell?
  10. Run the audit script. Which checks fail on your code, and what will you do about each?

Next: Embedding Scenarios.