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