Skip to main content

rpi_cli/
key_trace.rs

1//! Env-gated key-event tracing for diagnosing terminal input problems
2//! (remote/mobile clients, "Enter inserted a newline", paste-vs-typed Enter…).
3//!
4//! Enable with `RPI_DEBUG_KEYS=1` (appends to `<temp>/rpi-keys.log`) or
5//! `RPI_DEBUG_KEYS=<path>` to pick the file. Every key the TUI dispatches is
6//! appended as one line — wall clock, key code, modifiers, event kind, plus a
7//! caller-supplied note (the paste-burst decision, for example):
8//!
9//! ```text
10//! 2026-09-24T12:00:01.234Z code=Char('h') mods=NONE kind=Press
11//! 2026-09-24T12:00:01.235Z code=Enter mods=NONE kind=Press gap_ms=1 more_queued=false -> newline (paste burst, Windows only)
12//! ```
13//!
14//! What a remote client sends for Enter varies. A phone/RDP client that
15//! injects a real key event shows up as `code=Enter mods=NONE` (and
16//! `mods=SHIFT` when Shift is held), while one that encodes Enter as a bare LF
17//! shows up as `code=Char('j') mods=CONTROL` — in raw mode crossterm decodes the
18//! byte `0x0A` through its control-code table (`0x0A` is both LF and Ctrl+J).
19//! On Windows the paste-burst decision also prints `more_queued=`, which is why
20//! the probe ignores a key's own `Release` event.
21//!
22//! Tracing is off unless the env var is set: when disabled the cost is a single
23//! `OnceLock` lookup per key.
24
25use std::io::Write;
26use std::sync::OnceLock;
27use std::time::{SystemTime, UNIX_EPOCH};
28
29use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
30
31/// Resolved trace destination, or `None` when tracing is off.
32fn target() -> Option<&'static std::path::PathBuf> {
33    static TARGET: OnceLock<Option<std::path::PathBuf>> = OnceLock::new();
34    TARGET
35        .get_or_init(|| {
36            let value = std::env::var("RPI_DEBUG_KEYS").ok()?;
37            let value = value.trim();
38            if value.is_empty() || value == "0" {
39                return None;
40            }
41            if value == "1" || value.eq_ignore_ascii_case("true") {
42                Some(std::env::temp_dir().join("rpi-keys.log"))
43            } else {
44                Some(std::path::PathBuf::from(value))
45            }
46        })
47        .as_ref()
48}
49
50/// Whether key tracing is enabled.
51pub fn enabled() -> bool {
52    target().is_some()
53}
54
55/// Path the trace is written to (for the startup banner / user hints).
56pub fn path() -> Option<&'static std::path::Path> {
57    target().map(std::path::PathBuf::as_path)
58}
59
60/// Append one key event, with an optional note describing what the TUI decided.
61pub fn key(event: &KeyEvent, note: &str) {
62    let Some(path) = target() else {
63        return;
64    };
65    let mut line = format!(
66        "{} code={} mods={} kind={:?}",
67        timestamp(),
68        code_name(event.code),
69        modifiers_name(event.modifiers),
70        event.kind
71    );
72    if !note.is_empty() {
73        line.push(' ');
74        line.push_str(note);
75    }
76    line.push('\n');
77    append(path, &line);
78}
79
80/// Append a free-form note (startup banner, terminal info, …).
81pub fn note(text: &str) {
82    let Some(path) = target() else {
83        return;
84    };
85    append(path, &format!("{} {text}\n", timestamp()));
86}
87
88/// Paste-boundary diagnostics, independent of the per-key/content trace.
89/// Only caller-supplied counts and decisions are recorded, never clipboard text.
90/// Enable with RPI_DEBUG_PASTE=1 or a log file path for one test session.
91pub fn paste_note(note: impl FnOnce() -> String) {
92    static TARGET: OnceLock<Option<std::path::PathBuf>> = OnceLock::new();
93    let path = TARGET.get_or_init(|| {
94        let value = std::env::var("RPI_DEBUG_PASTE").ok()?;
95        let value = value.trim();
96        let path = if value.is_empty() || value == "0" {
97            return None;
98        } else if value == "1" || value.eq_ignore_ascii_case("true") {
99            std::env::temp_dir().join("rpi-paste.log")
100        } else {
101            std::path::PathBuf::from(value)
102        };
103        append(
104            &path,
105            &format!(
106                "{} paste run pid={} version={}\n",
107                timestamp(),
108                std::process::id(),
109                env!("CARGO_PKG_VERSION")
110            ),
111        );
112        Some(path)
113    });
114    if let Some(path) = path {
115        append(path, &format!("{} paste {}\n", timestamp(), note()));
116    }
117}
118
119fn append(path: &std::path::Path, line: &str) {
120    if let Ok(mut file) = std::fs::OpenOptions::new()
121        .create(true)
122        .append(true)
123        .open(path)
124    {
125        let _ = file.write_all(line.as_bytes());
126    }
127}
128
129/// UTC timestamp, `2026-09-24T12:00:01.234Z`.
130fn timestamp() -> String {
131    let now = SystemTime::now()
132        .duration_since(UNIX_EPOCH)
133        .unwrap_or_default();
134    let millis = now.as_millis();
135    let secs = (millis / 1000) as i64;
136    let sub = millis % 1000;
137    let (year, month, day, hour, min, sec) = civil_from_unix(secs);
138    format!("{year:04}-{month:02}-{day:02}T{hour:02}:{min:02}:{sec:02}.{sub:03}Z")
139}
140
141/// Days-from-civil inverse (Howard Hinnant's algorithm) — avoids a date crate
142/// dependency for a diagnostic timestamp.
143fn civil_from_unix(secs: i64) -> (i64, u32, u32, u32, u32, u32) {
144    let days = secs.div_euclid(86_400);
145    let secs_of_day = secs.rem_euclid(86_400);
146    let z = days + 719_468;
147    let era = z.div_euclid(146_097);
148    let doe = z.rem_euclid(146_097);
149    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
150    let y = yoe + era * 400;
151    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
152    let mp = (5 * doy + 2) / 153;
153    let d = doy - (153 * mp + 2) / 5 + 1;
154    let m = if mp < 10 { mp + 3 } else { mp - 9 };
155    let y = if m <= 2 { y + 1 } else { y };
156    (
157        y,
158        m as u32,
159        d as u32,
160        (secs_of_day / 3600) as u32,
161        ((secs_of_day % 3600) / 60) as u32,
162        (secs_of_day % 60) as u32,
163    )
164}
165
166/// Readable key code — control characters are spelled out so an LF-as-Ctrl+J
167/// (the mobile/remote Enter case) is visible at a glance.
168fn code_name(code: KeyCode) -> String {
169    match code {
170        KeyCode::Char(c) if c.is_control() => {
171            format!("Char({c:?}) [U+{:04X}]", c as u32)
172        }
173        KeyCode::Char(c) => format!("Char({c:?})"),
174        other => format!("{other:?}"),
175    }
176}
177
178fn modifiers_name(modifiers: KeyModifiers) -> String {
179    if modifiers.is_empty() {
180        return "NONE".to_string();
181    }
182    let mut parts = Vec::new();
183    for (flag, name) in [
184        (KeyModifiers::CONTROL, "CONTROL"),
185        (KeyModifiers::SHIFT, "SHIFT"),
186        (KeyModifiers::ALT, "ALT"),
187        (KeyModifiers::SUPER, "SUPER"),
188        (KeyModifiers::HYPER, "HYPER"),
189        (KeyModifiers::META, "META"),
190    ] {
191        if modifiers.contains(flag) {
192            parts.push(name);
193        }
194    }
195    parts.join("|")
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn timestamp_matches_the_unix_epoch() {
204        assert_eq!(civil_from_unix(0), (1970, 1, 1, 0, 0, 0));
205        assert_eq!(civil_from_unix(1_700_000_000), (2023, 11, 14, 22, 13, 20));
206    }
207
208    #[test]
209    fn control_characters_and_modifiers_are_spelled_out() {
210        assert_eq!(code_name(KeyCode::Char('j')), "Char('j')");
211        assert_eq!(code_name(KeyCode::Char('\n')), "Char('\\n') [U+000A]");
212        assert_eq!(code_name(KeyCode::Enter), "Enter");
213        assert_eq!(
214            modifiers_name(KeyModifiers::CONTROL | KeyModifiers::SHIFT),
215            "CONTROL|SHIFT"
216        );
217        assert_eq!(modifiers_name(KeyModifiers::NONE), "NONE");
218    }
219}