thd75_repl/lib.rs
1//! Library crate for `thd75-repl`.
2//!
3//! This exposes the internal modules that need to be reachable from
4//! integration tests in `tests/` and from the binary entry point in
5//! `src/main.rs`.
6//!
7//! The binary is a thin wrapper that imports from this library via its
8//! own crate name.
9//!
10//! Public modules here are part of the REPL's internal API, not a
11//! stable library API for external consumers.
12
13#![deny(unsafe_code)]
14#![deny(missing_docs)]
15#![deny(clippy::all)]
16#![deny(clippy::pedantic)]
17#![deny(clippy::nursery)]
18#![allow(clippy::module_name_repetitions)]
19
20use std::collections::VecDeque;
21use std::sync::Mutex;
22use std::sync::OnceLock;
23use std::sync::atomic::{AtomicBool, AtomicI32, AtomicUsize, Ordering};
24
25/// Accessibility lint checker for REPL output lines.
26///
27/// Implements fourteen hard rules (R1 through R14) covering ASCII
28/// purity, line length, ANSI escapes, error and warning prefixes,
29/// list summaries, label-colon-value formatting, boolean rendering,
30/// and unit spelling. See `lint::Rule` for the full enumeration.
31pub mod lint;
32
33/// Pure format functions for every user-facing string.
34///
35/// Zero I/O, zero async, zero radio access. Tested directly with
36/// golden strings and the lint.
37pub mod output;
38
39/// Per-command detailed help text, returned by `help <command>`, plus
40/// the multi-line mode help blobs used by `help` with no arguments.
41pub mod help_text;
42
43/// Script mode: parse a file of REPL commands and execute them.
44pub mod script;
45
46/// Mock radio scenarios for integration tests (feature-gated).
47#[cfg(feature = "testing")]
48pub mod mock_scenarios;
49
50/// Accessibility compliance self-check for the `check` subcommand.
51pub mod check;
52
53/// Transmit confirmation prompt and script-mode gating.
54pub mod confirm;
55
56/// Global flag for timestamp output mode.
57///
58/// When `true`, the [`aprintln!`] macro prepends a `[HH:MM:SS]`
59/// timestamp prefix to every line. Set at startup from the
60/// `--timestamps`, `--local-time`, and `--utc-offset` CLI flags.
61pub static TIMESTAMPS: AtomicBool = AtomicBool::new(false);
62
63/// UTC offset in seconds, added to UTC time when forming timestamps.
64///
65/// Zero means UTC. Set at startup from `--utc-offset`, `--local-time`,
66/// or both; otherwise unused. Atomic so the [`aprintln!`] macro can
67/// read it without synchronisation.
68pub static UTC_OFFSET_SECS: AtomicI32 = AtomicI32::new(0);
69
70/// Global flag for verbose output mode.
71///
72/// When `false` (quiet mode), low-signal recurring events such as
73/// digipeat notifications, APRS query responses, raw packet dumps, and
74/// D-STAR voice-lost frames are suppressed. Defaults to `true`.
75pub static VERBOSE: AtomicBool = AtomicBool::new(true);
76
77/// Read the current verbose flag.
78#[must_use]
79pub fn is_verbose() -> bool {
80 VERBOSE.load(Ordering::Relaxed)
81}
82
83/// Default number of lines retained by the history buffer.
84///
85/// Screen-reader users rely on the `last` command to replay recent
86/// output without rewinding the buffer one line at a time; 30 lines
87/// covers most common single-screen contexts. See [`last_lines`] and
88/// [`record_output`].
89pub const HISTORY_CAPACITY_DEFAULT: usize = 30;
90
91/// Runtime-adjustable history capacity. Set from
92/// `--history-lines` at startup via [`set_history_capacity`].
93static HISTORY_CAPACITY: AtomicUsize = AtomicUsize::new(HISTORY_CAPACITY_DEFAULT);
94
95/// Rolling buffer of recent user-facing output lines, ordered oldest
96/// to newest. Appended to by every [`aprintln!`] invocation and read
97/// by the `last` REPL command.
98///
99/// Uses `OnceLock` rather than `LazyLock` to keep the accessible REPL
100/// crate compatible with older stable Rust releases.
101#[must_use]
102pub fn last_output() -> &'static Mutex<VecDeque<String>> {
103 static BUF: OnceLock<Mutex<VecDeque<String>>> = OnceLock::new();
104 BUF.get_or_init(|| Mutex::new(VecDeque::with_capacity(HISTORY_CAPACITY_DEFAULT)))
105}
106
107/// Update the history buffer capacity at startup.
108///
109/// Values of 0 are allowed (disables history, `last` will print the
110/// "no previous output" message). Existing entries beyond the new
111/// capacity are dropped from the front (oldest first).
112pub fn set_history_capacity(n: usize) {
113 HISTORY_CAPACITY.store(n, Ordering::Relaxed);
114 if let Ok(mut buf) = last_output().lock() {
115 while buf.len() > n {
116 let _ = buf.pop_front();
117 }
118 }
119}
120
121/// Append `line` to the history buffer, evicting the oldest entry
122/// when the buffer would exceed the configured capacity. Intended to
123/// be called only from the [`aprintln!`] macro.
124pub fn record_output(line: String) {
125 let cap = HISTORY_CAPACITY.load(Ordering::Relaxed);
126 if cap == 0 {
127 return;
128 }
129 if let Ok(mut buf) = last_output().lock() {
130 while buf.len() >= cap {
131 let _ = buf.pop_front();
132 }
133 buf.push_back(line);
134 }
135}
136
137/// Retrieve the most recent `n` lines from the history buffer.
138///
139/// Entries are returned oldest-to-newest. Passing `n == 0` always
140/// returns an empty vector. The returned vector clones the stored
141/// strings so the caller may hold them after the buffer advances.
142#[must_use]
143pub fn last_lines(n: usize) -> Vec<String> {
144 if n == 0 {
145 return Vec::new();
146 }
147 last_output().lock().map_or_else(
148 |_| Vec::new(),
149 |buf| {
150 let len = buf.len();
151 let start = len.saturating_sub(n);
152 buf.iter().skip(start).cloned().collect()
153 },
154 )
155}
156
157/// Print a line with an optional `[HH:MM:SS]` timestamp prefix and
158/// record it in the history buffer.
159///
160/// When [`TIMESTAMPS`] is enabled, prepends a time prefix to every
161/// output line so blind operators can track when events occurred
162/// without checking a clock. The time shown is UTC by default, or
163/// local time if [`UTC_OFFSET_SECS`] has been set from
164/// `--local-time` / `--utc-offset`.
165///
166/// Every printed line is also appended to the rolling history buffer
167/// so the `last` REPL command can replay recent output. Plain
168/// `println!` calls bypass this recording — use `aprintln!` whenever
169/// a line is user-facing and should be replayable.
170#[macro_export]
171macro_rules! aprintln {
172 ($($arg:tt)*) => {{
173 let body = format!($($arg)*);
174 let line = if $crate::TIMESTAMPS.load(
175 ::std::sync::atomic::Ordering::Relaxed,
176 ) {
177 #[allow(clippy::cast_possible_wrap)]
178 let utc_secs = ::std::time::SystemTime::now()
179 .duration_since(::std::time::UNIX_EPOCH)
180 .unwrap_or_default()
181 .as_secs() as i64;
182 let offset = i64::from(
183 $crate::UTC_OFFSET_SECS.load(::std::sync::atomic::Ordering::Relaxed),
184 );
185 let local_secs_signed = utc_secs + offset;
186 #[allow(clippy::cast_sign_loss)]
187 let local_secs = if local_secs_signed < 0 {
188 0u64
189 } else {
190 local_secs_signed as u64
191 };
192 let h = (local_secs / 3600) % 24;
193 let m = (local_secs / 60) % 60;
194 let s = local_secs % 60;
195 format!("[{h:02}:{m:02}:{s:02}] {body}")
196 } else {
197 body
198 };
199 println!("{line}");
200 $crate::record_output(line);
201 }};
202}
203
204#[cfg(test)]
205mod lib_tests {
206 //! Tests for the shared library state: history buffer mechanics.
207 //!
208 //! These tests mutate global state ([`HISTORY_CAPACITY`] and the
209 //! `OnceLock` buffer), so they must run serially. A file-local
210 //! mutex guards each test.
211 use super::{
212 HISTORY_CAPACITY_DEFAULT, last_lines, last_output, record_output, set_history_capacity,
213 };
214 use std::sync::Mutex;
215
216 /// Test guard: every test in this module locks this mutex first,
217 /// mutates global state, then drops the lock. Prevents interleaved
218 /// test executions from corrupting each other.
219 static TEST_GUARD: Mutex<()> = Mutex::new(());
220
221 fn reset() {
222 set_history_capacity(HISTORY_CAPACITY_DEFAULT);
223 if let Ok(mut buf) = last_output().lock() {
224 buf.clear();
225 }
226 }
227
228 #[test]
229 fn records_and_retrieves_lines() {
230 let _g = TEST_GUARD
231 .lock()
232 .unwrap_or_else(std::sync::PoisonError::into_inner);
233 reset();
234 record_output("alpha".to_string());
235 record_output("bravo".to_string());
236 record_output("charlie".to_string());
237 assert_eq!(last_lines(3), vec!["alpha", "bravo", "charlie"]);
238 assert_eq!(last_lines(1), vec!["charlie"]);
239 assert_eq!(last_lines(2), vec!["bravo", "charlie"]);
240 }
241
242 #[test]
243 fn evicts_oldest_when_full() {
244 let _g = TEST_GUARD
245 .lock()
246 .unwrap_or_else(std::sync::PoisonError::into_inner);
247 reset();
248 set_history_capacity(3);
249 record_output("one".to_string());
250 record_output("two".to_string());
251 record_output("three".to_string());
252 record_output("four".to_string());
253 assert_eq!(last_lines(10), vec!["two", "three", "four"]);
254 }
255
256 #[test]
257 fn last_lines_zero_returns_empty() {
258 let _g = TEST_GUARD
259 .lock()
260 .unwrap_or_else(std::sync::PoisonError::into_inner);
261 reset();
262 record_output("one".to_string());
263 assert!(last_lines(0).is_empty());
264 }
265
266 #[test]
267 fn set_capacity_zero_disables_recording() {
268 let _g = TEST_GUARD
269 .lock()
270 .unwrap_or_else(std::sync::PoisonError::into_inner);
271 reset();
272 set_history_capacity(0);
273 record_output("ignored".to_string());
274 assert!(last_lines(10).is_empty());
275 // Restore default so other tests see the expected state.
276 set_history_capacity(HISTORY_CAPACITY_DEFAULT);
277 }
278
279 #[test]
280 fn shrink_capacity_truncates_front() {
281 let _g = TEST_GUARD
282 .lock()
283 .unwrap_or_else(std::sync::PoisonError::into_inner);
284 reset();
285 set_history_capacity(5);
286 record_output("a".to_string());
287 record_output("b".to_string());
288 record_output("c".to_string());
289 record_output("d".to_string());
290 set_history_capacity(2);
291 assert_eq!(last_lines(10), vec!["c", "d"]);
292 }
293}