Skip to main content

keymap_term/
lib.rs

1//! # keymap-term — capture schema and capability-aware decoder
2//!
3//! `keymap-rs` is *measurement first*: before any capability-aware byte decoding
4//! is written, we record what real terminals actually send. This crate defines
5//! the on-disk schema for those recordings ([`Capture`]) plus the lossless hex
6//! codec used to store raw bytes safely in TOML.
7//!
8//! A capture is provenance-bearing ground truth: which terminal, whether tmux or
9//! SSH was in the path, when it was taken, what each key produced on the wire,
10//! and how each capability value was learned. Captures live under `captures/`,
11//! one file per terminal/session, and are the fixtures the decoder is validated
12//! against. The interactive recorder is the `keymap-probe` binary.
13//!
14//! The [`decode`] function turns recorded bytes back into `keymap_core::KeyInput`
15//! under a [`DecodeMode`], returning a [`Decoded`] outcome. It is a pure,
16//! state-free function built only from the byte shapes the committed captures
17//! contain (baseline and kitty-enhanced today).
18//!
19//! On top of it, [`reachability`] reports *empirical* per-terminal reachability:
20//! for one [`Capture`] it enumerates which recorded chords actually arrive as
21//! themselves (the empirical refinement of `keymap_core::legacy_form`'s static
22//! lower bound). This crate carries no terminal-I/O dependencies.
23//!
24//! ## Headless verification
25//!
26//! There is no `examples/` directory for this crate; the decoder is verified
27//! against the committed `captures/*.toml` fixtures by `tests/decode_fixtures.rs`,
28//! `tests/capture_invariants.rs`, and `tests/reachability_fixtures.rs`
29//! (`cargo test -p keymap-term`). New decoder behaviour is added by extending
30//! the captures, not by writing speculative byte shapes.
31
32mod decode;
33mod reachability;
34
35pub use decode::{DecodeMode, Decoded, decode};
36pub use reachability::{Reachability, reachability};
37
38use serde::{Deserialize, Serialize};
39
40/// A single recording session against one terminal environment.
41///
42/// This is a serialization DTO: new fields are added with `#[serde(default)]`
43/// for data-level backward compatibility, so it is intentionally *not*
44/// `#[non_exhaustive]` — callers (the recorder) construct it directly.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct Capture {
47    /// Environment metadata describing where and when this was recorded.
48    pub meta: Meta,
49    /// Capability values observed (e.g. kitty keyboard protocol support).
50    #[serde(default)]
51    pub capability: Vec<Capability>,
52    /// The keypresses recorded, with the raw bytes each produced.
53    #[serde(default)]
54    pub keypress: Vec<KeyPress>,
55}
56
57/// Where and when a [`Capture`] was taken — the index used to tell captures
58/// apart and to judge when one has gone stale.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct Meta {
61    /// RFC 3339 timestamp of when the capture was taken (freshness signal).
62    pub captured_at: String,
63    /// Version of the recorder that produced this file (format-migration hook).
64    pub harness_version: String,
65    /// The `$TERM` value, if set.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub term: Option<String>,
68    /// The `$TERM_PROGRAM` value, if set.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub term_program: Option<String>,
71    /// The `$TERM_PROGRAM_VERSION` value, if set.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub term_program_version: Option<String>,
74    /// Whether a tmux/screen multiplexer was in the path (changes encodings).
75    pub tmux: bool,
76    /// Whether the session was over SSH (the far terminal's identity is hidden).
77    pub ssh: bool,
78}
79
80/// How a capability value was learned. The same value is worth far less if it
81/// came from an environment-variable guess than from a query response.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "snake_case")]
84#[non_exhaustive]
85pub enum Provenance {
86    /// Learned from the terminal's reply to an active query (most reliable).
87    QueryResponse,
88    /// Inferred from an environment variable (a hint, not a contract).
89    EnvVar,
90    /// Entered by a human operator.
91    Manual,
92}
93
94/// One observed terminal capability.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct Capability {
97    /// Capability name, e.g. `"kitty_keyboard_protocol"`.
98    pub name: String,
99    /// The observed value, e.g. `"supported"` / `"unsupported"`.
100    pub value: String,
101    /// How `value` was learned.
102    pub provenance: Provenance,
103    /// The raw response bytes (hex), when learned from a query.
104    #[serde(default, skip_serializing_if = "Vec::is_empty")]
105    pub raw_response: Vec<String>,
106}
107
108/// One recorded keypress: what the operator intended versus what arrived.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct KeyPress {
111    /// Human label of the key the operator was asked to press, e.g. `"cmd+1"`.
112    pub intended: String,
113    /// The capability mode the terminal was in, e.g. `"baseline"`.
114    pub mode: String,
115    /// Raw bytes received on the wire (hex), the primary datum.
116    pub raw_bytes: Vec<String>,
117    /// Sizes of each `read()` chunk, preserving where reads split a sequence.
118    #[serde(default, skip_serializing_if = "Vec::is_empty")]
119    pub read_chunks: Vec<usize>,
120    /// `KeyInput` the `intended` label parsed to, e.g. `"Char('1')"`. The
121    /// *expected* key; comparing it to `raw_bytes` reveals non-delivery.
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub expected_key: Option<String>,
124    /// Modifiers the `intended` label parsed to, e.g. `["SUPER"]`.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub expected_mods: Option<Vec<String>>,
127}
128
129/// Encodes bytes as lowercase two-digit hex strings (`0x1b` -> `"1b"`).
130///
131/// Raw terminal bytes include control characters and non-UTF-8 sequences, so
132/// they are stored as hex rather than as a string to keep captures lossless and
133/// human-diffable.
134#[must_use]
135pub fn to_hex(bytes: &[u8]) -> Vec<String> {
136    bytes.iter().map(|b| format!("{b:02x}")).collect()
137}
138
139/// Error returned when a hex string in a capture cannot be decoded.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct ParseHexError {
142    token: String,
143}
144
145impl core::fmt::Display for ParseHexError {
146    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
147        write!(f, "invalid hex byte token: {:?}", self.token)
148    }
149}
150
151impl std::error::Error for ParseHexError {}
152
153/// Decodes hex strings produced by [`to_hex`] back into bytes.
154///
155/// # Errors
156///
157/// Returns [`ParseHexError`] if any token is not exactly two hex digits.
158pub fn from_hex(hex: &[String]) -> Result<Vec<u8>, ParseHexError> {
159    hex.iter()
160        .map(|tok| {
161            if tok.len() == 2 {
162                u8::from_str_radix(tok, 16).map_err(|_| ParseHexError { token: tok.clone() })
163            } else {
164                Err(ParseHexError { token: tok.clone() })
165            }
166        })
167        .collect()
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn hex_round_trips_including_control_and_high_bytes() {
176        let bytes = vec![0x00, 0x1b, 0x5b, 0x41, 0x7f, 0xff];
177        let hex = to_hex(&bytes);
178        assert_eq!(hex, ["00", "1b", "5b", "41", "7f", "ff"]);
179        assert_eq!(from_hex(&hex).unwrap(), bytes);
180    }
181
182    #[test]
183    fn from_hex_rejects_malformed_tokens() {
184        assert!(from_hex(&["1".to_string()]).is_err());
185        assert!(from_hex(&["zz".to_string()]).is_err());
186        assert!(from_hex(&["1bb".to_string()]).is_err());
187    }
188
189    #[test]
190    fn capture_round_trips_through_toml() {
191        let capture = Capture {
192            meta: Meta {
193                captured_at: "2026-05-26T01:00:00Z".to_string(),
194                harness_version: "0.1.0".to_string(),
195                term: Some("xterm-256color".to_string()),
196                term_program: Some("iTerm.app".to_string()),
197                term_program_version: Some("3.5.0".to_string()),
198                tmux: false,
199                ssh: false,
200            },
201            capability: vec![Capability {
202                name: "kitty_keyboard_protocol".to_string(),
203                value: "unsupported".to_string(),
204                provenance: Provenance::QueryResponse,
205                raw_response: to_hex(&[0x1b, 0x5b, 0x3f, 0x63]),
206            }],
207            keypress: vec![KeyPress {
208                intended: "cmd+1".to_string(),
209                mode: "baseline".to_string(),
210                raw_bytes: to_hex(&[0x31]),
211                read_chunks: vec![1],
212                expected_key: Some("Char('1')".to_string()),
213                expected_mods: Some(vec!["SUPER".to_string()]),
214            }],
215        };
216
217        let serialized = toml::to_string(&capture).unwrap();
218        let parsed: Capture = toml::from_str(&serialized).unwrap();
219        assert_eq!(parsed.meta.term_program.as_deref(), Some("iTerm.app"));
220        assert_eq!(parsed.capability[0].provenance, Provenance::QueryResponse);
221        assert_eq!(parsed.keypress[0].intended, "cmd+1");
222        assert_eq!(from_hex(&parsed.keypress[0].raw_bytes).unwrap(), [0x31]);
223    }
224}