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}