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}