thd75_repl/script.rs
1//! Script mode: read commands from a file and execute them.
2//!
3//! Script mode is a dual-use feature. For users, it provides a way to
4//! pre-configure the radio by saving a sequence of commands in a file
5//! and replaying them (contest prep, net startup, morning checks).
6//! For tests, it is the vehicle that lets integration tests drive the
7//! full REPL loop from a file and capture output via subprocess
8//! spawning.
9//!
10//! ## Format
11//!
12//! - `#` at line start: comment, skipped
13//! - Blank lines: skipped
14//! - Leading and trailing whitespace on each command line is trimmed
15//! - Everything else: sent through the same dispatcher as interactive
16//! input
17//! - `exit` or `quit` ends the script and exits the REPL
18
19use std::io::BufRead;
20use std::path::Path;
21
22/// Parsed lines from a script file, with comments and blanks removed.
23#[derive(Debug, Default, Clone)]
24pub struct Script {
25 /// The non-comment, non-blank command lines in the order they
26 /// appeared in the source file.
27 pub commands: Vec<String>,
28}
29
30impl Script {
31 /// Parse a script from any reader.
32 ///
33 /// Returns a [`Script`] containing every non-blank, non-comment
34 /// line from `reader`, with leading and trailing whitespace
35 /// stripped. Order is preserved.
36 ///
37 /// # Errors
38 ///
39 /// Propagates any I/O error from reading the underlying reader.
40 pub fn from_reader<R: BufRead>(reader: R) -> std::io::Result<Self> {
41 let mut commands = Vec::new();
42 for line in reader.lines() {
43 let line = line?;
44 let trimmed = line.trim();
45 if trimmed.is_empty() || trimmed.starts_with('#') {
46 continue;
47 }
48 commands.push(trimmed.to_string());
49 }
50 Ok(Self { commands })
51 }
52
53 /// Parse a script from a file path.
54 ///
55 /// Pass `-` to read from standard input instead of a regular file.
56 ///
57 /// # Errors
58 ///
59 /// Returns an I/O error if the file cannot be opened or read.
60 pub fn from_path(path: &Path) -> std::io::Result<Self> {
61 if path.as_os_str() == "-" {
62 let stdin = std::io::stdin();
63 let reader = stdin.lock();
64 Self::from_reader(reader)
65 } else {
66 let file = std::fs::File::open(path)?;
67 Self::from_reader(std::io::BufReader::new(file))
68 }
69 }
70}
71
72#[cfg(test)]
73mod tests {
74 use super::*;
75 use std::io::Cursor;
76
77 fn parse(text: &str) -> Script {
78 Script::from_reader(Cursor::new(text.as_bytes())).expect("parse script")
79 }
80
81 #[test]
82 fn skips_comments() {
83 let s = parse("# first\nid\n# second\nbattery\n");
84 assert_eq!(s.commands, vec!["id".to_string(), "battery".to_string()]);
85 }
86
87 #[test]
88 fn skips_blank_lines() {
89 let s = parse("id\n\n\nbattery\n");
90 assert_eq!(s.commands, vec!["id".to_string(), "battery".to_string()]);
91 }
92
93 #[test]
94 fn trims_whitespace() {
95 let s = parse(" id \n\tbattery\n");
96 assert_eq!(s.commands, vec!["id".to_string(), "battery".to_string()]);
97 }
98
99 #[test]
100 fn preserves_order() {
101 let s = parse("one\ntwo\nthree\n");
102 assert_eq!(s.commands, vec!["one", "two", "three"]);
103 }
104
105 #[test]
106 fn empty_file_is_valid() {
107 let s = parse("");
108 assert!(s.commands.is_empty());
109 }
110
111 #[test]
112 fn only_comments_is_valid() {
113 let s = parse("# foo\n# bar\n\n");
114 assert!(s.commands.is_empty());
115 }
116}