Lab 20: Extract the Core (Milestone 13)
Background
No new features. This lab is an audit and a refactor: stabilize the public APIs, enforce the boundaries mechanically, prove four consumers, and — optionally, last — expose a C ABI.
The output is not a program. It is a set of guarantees, each backed by a check that runs in CI.
Why This Lab Matters
- A boundary that is not mechanically enforced decays. Every one of them, always, silently.
- "Four consumers" is the difference between an architecture and a description of one.
Prerequisites
- Sections 1–4 complete.
- Crate Boundaries, Embedding Scenarios, and FFI and Bindings read.
Predict First
- Run the audit script from Crate Boundaries. How many checks will fail?
- Does
terminal-corecompile forwasm32-unknown-unknowntoday? If not, what is the first error? - How many
pubitems doesterminal-coreexpose? How many should it?
Step 1: Run the Audit
bash scripts/boundary-audit.sh
Do this before reading further, and write down what fails. The failures are the lab.
Typical results on a first run:
| Check | Common failure | Fix |
|---|---|---|
terminal-core has no OS deps | libc pulled in by a stray #[cfg(unix)] or a time call | Move it to terminal-pty, or remove it |
wasm32 build | std::time::Instant in the core (unavailable on that target) | Pass timestamps in, or use a feature flag |
terminal-mux has no GUI deps | A shared "theme" type living in terminal-gui | Move Theme to terminal-render-model |
| Platform cfgs confined | cfg(target_os = "macos") in the input encoder for Option-as-Meta | Make it a config field, not a cfg |
| Public APIs documented | #![deny(missing_docs)] not set | Set it, then write the docs |
No escape bytes in terminal-pty | A hardcoded reset sequence in the spawn path | It belongs in the caller |
Note: That fourth row is the interesting one. Platform behavior differences (should Option be Meta?) are configuration, not compilation targets. Making them
cfgs means you cannot test the other platform's behavior, and it silently makes the crate non-portable. Make them config fields with platform-appropriate defaults.
Step 2: Stabilize the Public APIs
#![allow(unused)] fn main() { // At the top of every library crate: #![deny(missing_docs)] #![warn(clippy::all)] //! One-paragraph crate documentation: what this crate is FOR and, explicitly, //! what it does NOT do. The negative half is the more useful one. }
Then, per crate, apply the checklist:
□ Every `pub` item is intentional. Default to private and promote deliberately.
□ No `pub` field on a struct that has an invariant. Accessors instead.
□ No `pub` type that leaks a dependency's type in its signature
(unless that dependency is a public, semver-stable part of your API).
□ Every function documents its panics, or does not panic.
□ Every `unsafe` block has a SAFETY comment stating the invariant it relies on.
□ Constructors take configuration; nothing reads a global.
□ `#[non_exhaustive]` on enums and config structs you expect to extend.
□ Names are the domain's, not the implementation's:
`Terminal::advance` not `Terminal::feed_bytes_to_parser`.
Find accidental pub:
cargo doc --no-deps -p terminal-core --open
# Read EVERY item on the page. Anything you cannot justify in one sentence
# should be private. This takes twenty minutes and is the single most valuable
# part of the lab.
cargo public-api --diff-git-checkouts main HEAD # if you have it installed
The #[non_exhaustive] decision:
#![allow(unused)] fn main() { /// Configuration for a Terminal. /// /// `#[non_exhaustive]` so that adding a field is not a breaking change. /// Callers must construct with `..Default::default()`. #[non_exhaustive] #[derive(Clone, Debug)] pub struct TerminalConfig { pub scrollback_limit: usize, pub tab_width: usize, pub ambiguous_width_is_wide: bool, pub clipboard_policy: ClipboardPolicy, } }
Step 3: Prove Four Consumers
The Milestone 13 criterion: the same terminal-core, unmodified, serving four consumers.
1. terminal-gui — the desktop application
2. terminal-mux — the server, headless, N instances
3. terminal-cli — the headless snapshot tool
4. the test harness — unit and golden tests, no OS
Write the proof as a test:
#![allow(unused)] fn main() { // tests/four_consumers.rs #[test] fn consumer_1_gui_uses_core_unmodified() { // Structural: the GUI depends on terminal-core and does not patch it. let manifest = std::fs::read_to_string("crates/terminal-gui/Cargo.toml").unwrap(); assert!(manifest.contains(r#"terminal-core = { path = "../terminal-core" }"#)); assert!(!manifest.contains("[patch"), "no patched core"); } #[test] fn consumer_2_mux_runs_headless() { // Fifty terminals, no display of any kind. let mut terms: Vec<Terminal> = (0..50) .map(|i| Terminal::new(24, 80, TerminalConfig { scrollback_limit: 100 * (i + 1), ..Default::default() })) .collect(); for t in &mut terms { t.advance(b"\x1b[31mheadless\x1b[0m\n"); } assert!(terms[0].snapshot_text().contains("headless")); } #[test] fn consumer_3_cli_produces_deterministic_snapshots() { let a = run_mini_term(&["--rows", "5", "--cols", "20"], &["printf", "hi\\n"]); let b = run_mini_term(&["--rows", "5", "--cols", "20"], &["printf", "hi\\n"]); assert_eq!(a, b); } #[test] fn consumer_4_test_harness_needs_no_os() { // No PTY, no thread, no file, no clock. If this needs anything else, the // boundary is wrong. let mut t = Terminal::new(24, 80, TerminalConfig::default()); t.advance(b"\x1b[2J\x1b[HHello"); assert!(t.snapshot_text().starts_with("Hello")); } }
And the forty-line headless simulator, which is the real acceptance test:
// experiments/headless/src/main.rs // If this program needs ANYTHING beyond terminal-core, the boundary is wrong. use std::io::Read; use terminal_core::{Terminal, TerminalConfig}; fn main() { let rows: usize = std::env::var("ROWS").ok().and_then(|s| s.parse().ok()).unwrap_or(24); let cols: usize = std::env::var("COLS").ok().and_then(|s| s.parse().ok()).unwrap_or(80); let mut term = Terminal::new(rows, cols, TerminalConfig::default()); let mut input = Vec::new(); std::io::stdin().read_to_end(&mut input).unwrap(); term.advance(&input); print!("{}", term.snapshot_text()); }
printf '\033[31mred\033[0m\nhello\n' | cargo run -p headless
Step 4: WASM
rustup target add wasm32-unknown-unknown
cargo build --target wasm32-unknown-unknown \
-p terminal-protocol -p terminal-core -p terminal-render-model -p terminal-input
Common failures and their fixes:
| Error | Cause | Fix |
|---|---|---|
Instant::now unsupported | A timestamp in the core | Pass time in from the caller, or feature-gate |
std::fs not found | The core reads a config file | Configuration is passed down, not read |
libc fails to build | A transitive dependency needs an OS | Find it with cargo tree and remove it |
getrandom fails | Something wants entropy | Usually a HashMap default hasher — use BTreeMap or a fixed hasher in the core |
That last one is subtle and common: std::collections::HashMap's default hasher needs randomness,
which wasm32-unknown-unknown does not provide without a feature. In a terminal core, a
deterministic BTreeMap or a fixed-seed hasher is arguably better anyway — determinism is a virtue
here.
Step 5: Write the Boundary Defense
A document, checked into the repo, with one section per crate:
## terminal-core
**Responsibility.** Turns parsed VT actions into screen state.
**Must not know about.** File descriptors, processes, threads, fonts, pixels,
windows, sockets, or the filesystem.
**Capability protected.** Running N terminals headlessly in a multiplexer server,
compiling to WebAssembly, and running the entire test suite in under two seconds.
**How the boundary is enforced.**
- `cargo tree -p terminal-core | grep -E 'nix|rustix|libc|mio|tokio|winit'`
must be empty (CI).
- `cargo build --target wasm32-unknown-unknown -p terminal-core` (CI).
- No `cfg(target_os)` anywhere in the crate (CI grep).
**What would break if it broke.** The multiplexer could not run without a display,
which removes detach/attach — the entire point of Section 4. Tests would need a
PTY, taking the suite from 2 seconds to 2 minutes, at which point people stop
running it.
**Known tension.** `terminal-input` depends on this crate solely for
`TerminalModes`. Moving the mode flags to `terminal-protocol` would remove that
edge. Not yet done because [reason].
That last section — known tension — is the mark of an honest architecture document. Every real design has them; hiding them is how they become surprises.
Step 6 (Optional, Last): The C ABI
Only if you have a consumer. See FFI and Bindings.
cargo new --lib crates/terminal-ffi --name terminal-ffi
# crate-type = ["cdylib", "staticlib"], panic = "unwind"
cbindgen --crate terminal-ffi --output include/terminal.h
cc examples/demo.c -Iinclude -Ltarget/release -lterminal_ffi -o demo && ./demo
The acceptance criterion is a C program that links and runs in CI, not a header that compiles.
Expected Output
$ bash scripts/boundary-audit.sh
=== 1. terminal-protocol has no dependencies === OK
=== 2. terminal-core has no OS dependencies === OK
=== 3. terminal-mux has no GUI dependencies === OK
=== 4. terminal-input has no windowing deps === OK
=== 5. The portable crates build for wasm32 === OK
=== 6. Platform cfgs confined to pty and gui === OK
=== 7. Public APIs are documented === OK
=== 8. No escape bytes in terminal-pty === OK
ALL BOUNDARY CHECKS PASSED
$ cargo test --workspace
test result: ok. 247 passed; 0 failed
Finished in 1.84s ← the whole suite, because the core needs no OS
$ printf '\033[31mred\033[0m\n' | cargo run -q -p headless
red
$ cargo run -q -p mini-term -- run --rows 3 --cols 20 -- printf 'hi\n'
hi
Debugging Steps
wasm32 fails on a transitive dependency
cargo tree -p terminal-core -i libc # who pulled libc in?
-i (invert) shows the path to a dependency, which is exactly what you need.
The audit's "no OS deps" check fails but you cannot see why
cargo tree includes dev-dependencies by default in some versions. Use
cargo tree -p terminal-core --edges normal to see only real dependencies.
#![deny(missing_docs)] produces two hundred errors
Expected. Work through them; most are one line. The exercise is worthwhile: writing the doc comment is when you notice that a function's name and its behavior disagree.
The four-consumer test passes but feels like cheating
It probably is, if the consumers are all in your workspace using path dependencies. The stronger
version: publish terminal-core to a local registry and depend on it by version. If that works, the
boundary is real.
The C example segfaults
Ownership. Check that every _new has exactly one _free, that no pointer is used after free, and
run under ASan.
Experiment
CLAIM. The boundaries buy measurable properties, and removing one costs something you can observe.
METHOD. Deliberately break a boundary and measure the consequence.
# 1. Baseline.
time cargo test -p terminal-core # note the time
cargo build --target wasm32-unknown-unknown -p terminal-core && echo "WASM OK"
# 2. Break it: add a "convenience" method to Terminal that spawns a shell.
# (This is the exact API a colleague will propose.)
# impl Terminal { pub fn spawn_shell(&mut self) -> io::Result<()> { ... } }
# Requires terminal-pty → requires libc.
# 3. Measure the damage.
cargo build --target wasm32-unknown-unknown -p terminal-core # FAILS
time cargo test -p terminal-core # slower? by how much?
cargo tree -p terminal-core | wc -l # how many new deps?
# 4. Revert, and write down the three properties you lost.
PREDICTION. Before step 3: which of the four consumers still work? How much slower is the test suite? How many transitive dependencies did one convenience method add?
RESULT. That number — dependencies added by one convenience method — is the most persuasive argument for the boundary you will ever have. Keep it.
Test
#![allow(unused)] fn main() { #[test] fn boundary_audit_passes() { // The audit script, as a test, so it runs with `cargo test` too. let out = std::process::Command::new("bash") .arg("scripts/boundary-audit.sh").output().unwrap(); assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); } #[test] fn core_public_api_is_documented() { let out = std::process::Command::new("cargo") .args(["doc", "--no-deps", "-p", "terminal-core"]).output().unwrap(); let stderr = String::from_utf8_lossy(&out.stderr); assert!(!stderr.contains("missing documentation"), "{stderr}"); } #[test] fn config_is_non_exhaustive_so_fields_can_be_added() { // Constructing without ..Default::default() must not compile for external // crates; a compile-fail test (trybuild) is the rigorous version. let _ = TerminalConfig { scrollback_limit: 100, ..Default::default() }; } #[test] fn terminal_can_be_constructed_many_times_with_different_configs() { let a = Terminal::new(24, 80, TerminalConfig { scrollback_limit: 10, ..Default::default() }); let b = Terminal::new(24, 80, TerminalConfig { scrollback_limit: 99_999, ..Default::default() }); assert_ne!(a.config().scrollback_limit, b.config().scrollback_limit); } #[test] fn the_whole_suite_is_fast() { // A property worth asserting: a slow suite is a suite nobody runs. If this // fails, something acquired an OS dependency. // (Measured in CI rather than here; documented as a target.) } }
Challenge Extensions
- Publish
terminal-protocolandterminal-coreto a local registry and have the other crates depend on them by version. This makes the boundary real rather than a path alias. cargo public-apiin CI, failing on unintentional API changes and producing a diff for review.- Semver discipline: tag
0.1.0, then make a breaking change and observe whatcargo semver-checkssays. - The WASM demo: replay an
asciinemarecording onto a canvas in the browser, using onlyterminal-core. - The C ABI with a CI-tested C example, plus ASan.
- Write the architecture document as a real README for
terminal-core, aimed at a stranger who wants to embed it. Then have someone actually try, and fix what they trip over.
Deliverables
- The boundary audit script, passing, and running in CI.
-
#![deny(missing_docs)]on every library crate, with the docs written. -
Every
pubitem justified; accidental ones made private. -
terminal-protocol,terminal-core,terminal-render-model, andterminal-inputbuild forwasm32-unknown-unknown. - The four-consumer test passing, plus the forty-line headless simulator.
- The boundary-defense document, including a "known tension" section per crate.
-
Platform behavior differences expressed as configuration, not
cfg. - The break-a-boundary experiment, with the dependency-count number recorded.
- Optional: a C ABI with a CI-tested C example program.
Validation / Self-check
- Which audit checks failed on your first run, and what did each reveal?
- Why should platform behavior differences be config rather than
cfg? - What does
#[non_exhaustive]buy, and what does it cost callers? - Why is "four consumers" the criterion rather than "a clean design"?
- What did the WASM build fail on first, and what did the fix teach you?
- Why is a path dependency a weaker proof of a boundary than a version dependency?
- What is a "known tension", and why does an honest architecture document have them?
- In the break-a-boundary experiment: how many dependencies did one convenience method add, and which consumers broke?
- Write the forty-line headless simulator from memory.
- A colleague proposes
Terminal::spawn_shell(). Give the one-sentence answer, with the number.
Section 5 Complete
Your workspace is now a reusable terminal engine with mechanically-enforced boundaries, four proven consumers, and a documented rationale for every line you drew.
What remains: making it actually compatible with real programs. That is Milestone 14.
Next: Testing Strategy.