Skip to main content

libsurfer/
batch_commands.rs

1use camino::Utf8PathBuf;
2use eyre::WrapErr as _;
3use futures::FutureExt as _;
4use tracing::{error, info, trace};
5
6use crate::{
7    SystemState,
8    async_util::perform_async_work,
9    channels::checked_send,
10    message::Message,
11    wave_source::{LoadProgress, LoadProgressStatus},
12};
13
14impl SystemState {
15    /// After user messages are addressed, we try to execute batch commands as they are ready to run
16    pub(crate) fn handle_batch_commands(&mut self) {
17        let mut should_exit = false;
18        // we only execute commands while we aren't waiting for background operations to complete
19        while self.can_start_batch_command() {
20            if let Some(cmd) = self.batch_messages.pop_front() {
21                if matches!(cmd, Message::Exit) {
22                    should_exit = true;
23                }
24                info!("Applying batch command: {cmd:?}");
25                self.update(cmd);
26            } else {
27                break; // no more messages
28            }
29        }
30
31        // if there are no messages and all operations have completed, we are done
32        if !self.batch_messages_completed
33            && self.batch_messages.is_empty()
34            && self.can_start_batch_command()
35        {
36            self.batch_messages_completed = true;
37
38            if should_exit {
39                info!("Exiting due to batch command");
40                let sender = self.channels.msg_sender.clone();
41                checked_send(&sender, Message::Exit);
42            }
43        }
44    }
45
46    /// Returns whether it is OK to start a new batch command.
47    pub(crate) fn can_start_batch_command(&self) -> bool {
48        // if the progress tracker is none -> all operations have completed
49        self.progress_tracker.is_none()
50    }
51
52    /// Returns true once all batch commands have been completed and their effects are all executed.
53    #[cfg(test)]
54    pub(crate) fn batch_commands_completed(&self) -> bool {
55        debug_assert!(
56            self.batch_messages_completed || !self.batch_messages.is_empty(),
57            "completed implies no commands"
58        );
59        self.batch_messages_completed
60    }
61
62    pub fn add_batch_commands<I: IntoIterator<Item = String>>(&mut self, commands: I) {
63        let messages = self.parse_batch_commands(commands);
64        self.add_batch_messages(messages);
65    }
66
67    #[inline(always)]
68    pub fn add_batch_messages<I: IntoIterator<Item = Message>>(&mut self, messages: I) {
69        messages
70            .into_iter()
71            .for_each(|msg| self.add_batch_message(msg));
72    }
73
74    #[inline(always)]
75    pub fn add_batch_message(&mut self, msg: Message) {
76        self.batch_messages.push_back(msg);
77        self.batch_messages_completed = false;
78    }
79
80    #[inline(always)]
81    fn parse_batch_commands<I: IntoIterator<Item = String>>(&mut self, cmds: I) -> Vec<Message> {
82        trace!("Parsing batch commands");
83
84        cmds
85            .into_iter()
86            // Add line numbers
87            .enumerate()
88            // trace
89            .map(|(no, line)| {
90                trace!("{no: >2} {line}");
91                (no, line)
92            })
93            // Make the line numbers start at 1 as is tradition
94            .map(|(no, line)| (no + 1, line))
95            .map(|(no, line)| (no, line.trim().to_string()))
96            // NOTE: Safe unwrap. Split will always return one element
97            .map(|(no, line)| (no, line.split('#').next().unwrap().to_string()))
98            .filter(|(_no, line)| !line.is_empty())
99            .flat_map(|(no, line)| {
100                line.split(';')
101                    .map(|cmd| (no, cmd.trim().to_string()))
102                    .collect::<Vec<_>>()
103            })
104            .filter_map(|(no, command)| {
105                if let Some(path_str) = command.strip_prefix("run_command_file ") {
106                    // Load commands from other file in place, otherwise they will be
107                    // loaded when the corresponding message is processed, leading to
108                    // a different position in the processing than expected.
109                    #[cfg(not(target_arch = "wasm32"))]
110                    {
111                        let path_str = path_str.trim().trim_matches('"'); // remove quotes if present
112                        if path_str.is_empty() {
113                            error!("Empty path in run_command_file on line {no}");
114                            return None;
115                        }
116                        match Utf8PathBuf::from_path_buf(path_str.into()) {
117                            Ok(utf8_path) => {
118                                self.add_batch_commands(read_command_file(&utf8_path));
119                            }
120                            Err(_) => {
121                                error!("Invalid UTF-8 path in run_command_file on line {no}: {path_str}");
122                            }
123                        }
124                    }
125                    #[cfg(target_arch = "wasm32")]
126                    error!("Cannot use run_command_file in command files running on WASM");
127                    None
128                } else {
129                    Some(Message::ExecuteBatchCommand { line: no, command })
130                }
131            })
132            .collect::<Vec<_>>()
133    }
134
135    pub(crate) fn load_commands_from_url(&mut self, url: String) {
136        #[cfg(all(not(target_arch = "wasm32"), feature = "https"))]
137        crate::async_util::ensure_rustls_crypto_provider();
138        let sender = self.channels.msg_sender.clone();
139        let url_ = url.clone();
140        perform_async_work(async move {
141            let maybe_response = reqwest::get(&url)
142                .map(|e| e.with_context(|| format!("Failed to fetch {url}")))
143                .await
144                .and_then(|response| {
145                    response.error_for_status().with_context(|| {
146                        format!("Failed to fetch {url}: server returned error status")
147                    })
148                });
149            let response: reqwest::Response = match maybe_response {
150                Ok(r) => r,
151                Err(e) => {
152                    error!("{e:?}");
153                    return;
154                }
155            };
156
157            // load the body to get at the file
158            let bytes = response
159                .bytes()
160                .map(|e| e.with_context(|| format!("Failed to download {url}")))
161                .await;
162
163            let bytes = match bytes {
164                Ok(bytes) => bytes,
165                Err(e) => {
166                    error!("{e:?}");
167                    return;
168                }
169            };
170            checked_send(&sender, Message::CommandFileDownloaded(url, bytes));
171        });
172
173        self.progress_tracker = Some(LoadProgress::new(LoadProgressStatus::Downloading(url_)));
174    }
175}
176
177#[must_use]
178pub fn read_command_file(cmd_file: &Utf8PathBuf) -> Vec<String> {
179    std::fs::read_to_string(cmd_file)
180        .map_err(|e| error!("Failed to read commands from {cmd_file}. {e:#?}"))
181        .ok()
182        .map(|file_content| file_content.lines().map(str::to_string).collect())
183        .unwrap_or_default()
184}
185
186#[must_use]
187pub fn read_command_bytes(bytes: Vec<u8>) -> Vec<String> {
188    String::from_utf8(bytes)
189        .map_err(|e| error!("Failed to read commands from file. {e:#?}"))
190        .ok()
191        .map(|file_content| file_content.lines().map(str::to_string).collect())
192        .unwrap_or_default()
193}